[Unity] NTPにより同期した日時を管理する

オンラインゲームなどでサーバ側と通信する際、日時管理が必要になったりします。
クライアントで設定されている日時(DateTime.Now)は各端末によって少しずれていたり不正目的で意図的に変更されたりする可能性があるため、信頼すべきではありません。
サーバと同期した日時を各クライアント端末で扱えるようにしたほうが望ましいです。

今回はUnityで開発するゲームアプリ内部でサーバと同期した日時を管理する方法を考えてみました。

■実装方針
サーバとの時刻同期にはNTPが一般的に用いられています。
サーバ側で設定されている時刻を通信遅延を考慮してクライアント側で取得できます。
この仕組みを用いてNTPで同期した時刻をゲームアプリ内部で保持するようにします。

NTPはTCPではなくUDPを使用するため、ゲームアプリ側からUDP接続する必要があります。
しかし、UnityにはUDPでパケット通信するAPIが(私が調査した限り)見当たらなかったため、.NET Frameworkのソケット通信を使って実現します。

ソケット通信の初期化は以下のようになります。

    // ソケットを開く
    IPEndPoint ipAny = new IPEndPoint(IPAddress.Any, 123);
    UdpClient sock = new UdpClient(ipAny);

そして、RFC-2030にしたがってリクエストデータをUdpClient.Send()でNTPサーバに送信します。

    // リクエスト送信
    byte[] sndData = new byte[48];
    sndData[0] = 0xB;
    sock.Send(sndData, sndData.Length, "NTPサーバーのアドレス", 123);

NTPサーバはご自身の環境に合わせてください。
リクエストの送信に成功するとサーバからタイムスタンプが格納されたレスポンスが返ってきます。
UdpClient.Receive()でレスポンスデータを受け取ります。

    // データ受信
    byte[] rcvData = sock.Receive(ref ipAny);

しかし、上記の処理には問題があります。
Send()やReceive()の通信処理は同期で行われるため、通信完了するまで処理が返ってこないことです。
フレーム処理内でこれをやってしまうとゲームアプリが一時的にフリーズしてしまいます。

したがって、非同期で行うようするのが望ましいでしょう。
UdpClientクラスにSendAsync()やReceiveAsync()等の非同期メソッドが用意されています。
しかし、これらは別スレッドで処理が行われるのでスレッドセーフを意識しなければなりません。

結果として、マルチスレッドを用いて通信する方法がUnityのフォーラムに書かれていたのでこちらを参考にさせていただきました。

■スクリプト
起動時にNTPでサーバ時刻を取得し、1秒毎に同期した時刻をログに出力するスクリプトです。

using UnityEngine;
using System;
using System.Collections;
using System.Net;
using System.Net.Sockets;
using System.Threading;

// NTP同期時刻を管理するクラス
public class NtpDate : MonoBehaviour {
    private DateTime ntpDate;   // NTP同期時刻
    private float rcvAppDate;   // NTP通信時のアプリ時刻

    private IPEndPoint ipAny;
    private UdpClient sock;
    private Thread thread;
    private volatile bool threadRunning = false;
    private byte[] rcvData;

    // 初期化
    void Start() {
        // リクエスト実行
        SyncDate();
        // 時刻表示(デバッグ用)
        StartCoroutine(ShowSyncDate());
    }

    // 同期時刻の表示
    private IEnumerator ShowSyncDate() {
        while ( true ) {
            yield return new WaitForSeconds(1);

            if ( Date == null ) {
                Debug.Log("Time is not received.");
            } else {
                Debug.Log("Receive date : " + Date.ToString());
            }
        }
    }

    // アプリケーション終了時処理
    void OnApplicationQuit() {
        if ( thread != null ) {
            thread.Abort();
        }
        if ( sock != null ) {
            sock.Close();
        }
    }

    // 時刻同期を行う
    public void SyncDate() {
        // リクエスト実行
        threadRunning = true;
        thread = new Thread(new ThreadStart(Request));
        thread.Start();

        // リクエスト待機コルーチン実行
        StartCoroutine(WaitForRequest());

        Debug.Log("Thread is started.");
    }

    // NTPサーバに対してリクエストを実行する
    private void Request() {
        // ソケットを開く
        ipAny = new IPEndPoint(IPAddress.Any, 123);
        sock = new UdpClient(ipAny);

        // リクエスト送信
        byte[] sndData = new byte[48];
        sndData[0] = 0xB;
        sock.Send(sndData, sndData.Length, "ntp.jst.mfeed.ad.jp", 123);

        // データ受信
        rcvData = sock.Receive(ref ipAny);

        // 実行中フラグクリア
        threadRunning = false;
    }

    // リクエスト待機コルーチン
    private IEnumerator WaitForRequest() {
        // リクエスト終了まで待機
        while ( threadRunning ) {
            yield return 0;
        }

        // アプリ時刻保存
        rcvAppDate = Time.realtimeSinceStartup;

        // 受信したバイナリデータをDateTime型に変換
        ntpDate = new DateTime(1900, 1, 1);
        var high = (double)BitConverter.ToUInt32(new byte[] { rcvData[43], rcvData[42], rcvData[41], rcvData[40] }, 0);
        var low = (double)BitConverter.ToUInt32(new byte[] { rcvData[47], rcvData[46], rcvData[45], rcvData[44] }, 0);
        ntpDate = ntpDate.AddSeconds(high + low / UInt32.MaxValue);

        // UTC→ローカル日時に変換
        ntpDate = ntpDate.ToLocalTime();
    }

    // NTP同期時刻
    public DateTime Date {
        get {
            return ntpDate.AddSeconds(Time.realtimeSinceStartup - rcvAppDate);
        }
    }
}

Start()内の初期化処理にてNTPのリクエストを送信して日時を受け取る処理を別スレッドで実行します。

    // 初期化
    void Start() {
        // リクエスト実行
        SyncDate();
        // 時刻表示(デバッグ用)
        StartCoroutine(ShowSyncDate());
    }

    // 時刻同期を行う
    public void SyncDate() {
        // リクエスト実行
        threadRunning = true;
        thread = new Thread(new ThreadStart(Request));
        thread.Start();

        // リクエスト待機コルーチン実行
        StartCoroutine(WaitForRequest());

        Debug.Log("Thread is started.");
    }

    // NTPサーバに対してリクエストを実行する
    private void Request() {
        // ソケットを開く
        ipAny = new IPEndPoint(IPAddress.Any, 123);
        sock = new UdpClient(ipAny);

        // リクエスト送信
        byte[] sndData = new byte[48];
        sndData[0] = 0xB;
        sock.Send(sndData, sndData.Length, "ntp.jst.mfeed.ad.jp", 123);

        // データ受信
        rcvData = sock.Receive(ref ipAny);

        // 実行中フラグクリア
        threadRunning = false;
    }

そして、コルーチンで通信が完了するまで待機し、正常な日時を取得できたらバイナリ形式のタイムスタンプをDateTime型に変換して保存します。

    // リクエスト待機コルーチン
    private IEnumerator WaitForRequest() {
        // リクエスト終了まで待機
        while ( threadRunning ) {
            yield return 0;
        }

        // アプリ時刻保存
        rcvAppDate = Time.realtimeSinceStartup;

        // 受信したバイナリデータをDateTime型に変換
        ntpDate = new DateTime(1900, 1, 1);
        var high = (double)BitConverter.ToUInt32(new byte[] { rcvData[43], rcvData[42], rcvData[41], rcvData[40] }, 0);
        var low = (double)BitConverter.ToUInt32(new byte[] { rcvData[47], rcvData[46], rcvData[45], rcvData[44] }, 0);
        ntpDate = ntpDate.AddSeconds(high + low / UInt32.MaxValue);

        // UTC→ローカル日時に変換
        ntpDate = ntpDate.ToLocalTime();
    }

タイムスタンプは8バイトのバイナリデータで1900年1月1日を基準に何秒経過したかを表す値となっています。
上位32ビットは秒、下位32ビットは秒の小数点部分です。(固定小数点)
ビッグエンディアンで格納されているので注意が必要です。

ここで取得した日時はあくまでも同期時の日時なので、後でサーバと同期した日時を知りたい場合は同期時からの経過時間を加算する必要があります。
UnityのTime.realtimeSinceStartupはゲーム起動時からの経過時間で且つ時間操作できないのでこれを用います。

        // アプリ時刻保存
        rcvAppDate = Time.realtimeSinceStartup;
    // NTP同期時刻
    public DateTime Date {
        get {
            return ntpDate.AddSeconds(Time.realtimeSinceStartup - rcvAppDate);
        }
    }

スクリプトを実行すると以下のようにクライアントの設定日時によらない日時が取得できます。

unity-ntp

流れは以上です。

今回のソケット通信を用いた方法はiPhoneやAndroidの端末では正しく実行できるかどうか分かりません。
これについては機会が出来たら確認したいです。

■参考サイト
Unity – Unity Manual
RFC-2030 日本語訳
NonSoft – NTPサーバの現在日時をシステム時計に設定するサンプル(C#.NET)
How can I receive UDP packets into Unity Webplayer? – Unity Answers