古事連記帖

趣味のこと、技術的なこと、適当につらつら書きます。

Windows ストアアプリでNTPから時刻を受信する

Windowsストアアプリ、つまるところの WinRT でNTPサーバーに接続して時刻を受信したい欲求があったので実装してみました。


NTPはおなじみの時刻同期に使用するプロトコルです。Windowsには標準でNTPクライアントが実装されているので、知らぬまま時刻同期されていたり、意識している人はNICTに接続するようにしたり、同期間隔を変更したりしていると思います。
ストアアプリでは、特にWindowsPhoneではシステム時刻を自由に設定できないのであまりNTPとつなげる意味はないですが、時計アプリを作りたいとか、OAuthなどで時刻を認証の一要素として使うなどで一時的な利用には十分つかえるはずです。


NTPについてのRFC原文はこちらに、有志による日本語訳はこちらにあります。
NTPはUDPを使って通信するのでソケット通信が必要です。ストアアプリ結構めんどくさいんすよ。


NTPで時刻同期をする際、伝送の遅延などを考慮する必要があるため、4つの時間が必要になります。

  • (クライアント) NTPへ接続を開始した時間
  • (サーバー) クライアントから要求を受けた時間
  • (サーバー) クライアントへ時刻を送信した時間
  • (クライアント) NTPから時刻を受けた、補正前の時間

これらはそれぞれRFCでは T1, T2, T3, T4 と定義されていて、遅延時間を考慮する計算式が書かれています。

NTPについてはこの辺がわかっていれば、あとは受信した情報を解析してやるだけです。


ストアアプリの場合のUDP通信は Windows.Networking.Sockets.DatagramSocket クラスを使います。
MSDNにも書いてありますが、このクラスを使う場合は

  1. クラスのインスタンスを作成する
  2. MessageReceived イベントをハンドラーに割り当てる
  3. BindEndpointAsync メソッドもしくは BindServiceNameAsync メソッドを呼び出す
  4. ConnectAsync メソッドを呼び出す

の順番にこなさないと InvalidOperationException で簡単に蹴られます。
さらに、MSDNには

アプリケーションが DatagramSocket オブジェクトのリモート エンドポイントからのデータの受信を望む場合、DatagramSocket が特定のリモート エンドポイントに束縛されるため、ConnectAsync メソッドを使用しないでください。代わりに BindServiceNameAsync または BindEndpointAsync メソッドを使用します。

と書いていますが、これは受信のみをおこなう場合だけであって、送信も受信もする場合は両方を呼び出さないとダメです。送信は出来ても受信できなかったり、その逆とかが発生して悲しみを生みます。(この辺でだいぶはまった…


このあたりの問題がさくっとわかると、UDP接続はあっさり終わります。

private DateTimeOffset requestStartDate;
private DateTimeOffset responseReceivedDate;
private DateTimeOffset serverReceivedDate;
private DateTimeOffset serverSentDate;

public async Task RequestNtpTimeAsync()
{
    // 送信開始時タイムスタンプ
    this.requestStartDate = DateTimeOffset.Now;

    var ntpData = new byte[48];
    // サーバーにクライアントからの要求であることを通知
    // LI: 00 警告なし(No Warning), VN: 011 (Version 3), Mode: 011 (Client: 3)
    ntpData[0] = 0x1B;

    var udpClient = new DatagramSocket();

    // 応答の受信イベントを定義
    udpClient.MessageReceived += UdpClient_MessageReceived;

    // 接続の確立
    // 受信データの受け口はシステム既定を使うのでこんな感じ。
    await udpClient.BindEndpointAsync(null, string.Empty);
    // NTP は UDP: 123
    await udpClient.ConnectAsync(new HostName("ntp.nict.jp"), "123");

    // 要求の送信
    var sender = new DataWriter(udpClient.OutputStream);
    sender.WriteBytes(ntpData);
    await sender.StoreAsync();
}

private void UdpClient_MessageReceived(DatagramSocket sender, DatagramSocketMessageReceivedEventArgs args)
{
    // あとにつづく
}


UDPを使ったNTPへの送信はこれでOKです。
この後はNTPサーバーから受け取る情報の解析です。
既にC#で実装されている SNTP in C# がわかりやすいかと思うので参考程度に。


サーバーからの情報はクライアントで渡すものとデータ構造は一緒です。最初に、同期した日に閏秒があるかが返ってきますが、今回は無視します。(Windowsでは閏秒を考慮しませんし)

private void UdpClient_MessageReceived(DatagramSocket sender, DatagramSocketMessageReceivedEventArgs args)
{
    // 受信完了したタイムスタンプ
    this.responseReceivedDate = DateTimeOffset.Now;

    var ntpData = new byte[48];
    var dr = args.GetDataReader();
    dr.ReadBytes(ntpData);

    // 受信タイムスタンプ
    this.serverReceivedDate = ComputeDate(GetMilliSeconds(ntpData, 32));
    // 送信タイムスタンプ
    this.serverSentDate = ComputeDate(GetMilliSeconds(ntpData, 40));

    // 誤差調整
    // 通信にしたことで発生した遅延の計算
    var delay = ((this.responseReceivedDate - this.requestStartDate) - (this.ServerReceivedDate - this.ServerSentDate));
    // サーバーとクライアントの時刻の差
    var diff =
        TimeSpan.FromMilliseconds(
            ((this.ServerReceivedDate - this.requestStartDate) + (this.ServerSentDate - this.responseReceivedDate)).TotalMilliseconds / 2);

    // 補正後の値
    var corrected = (DateTimeOffset.Now + diff - delay);
}

private static ulong GetMilliSeconds(byte[] data, int offset)
{
    // この辺は SNTP in C# に書かれているコードをほとんどコピーしたやーつ
    // 64ビットタイムスタンプをミリ秒に変換するコード
    var intPart = data.Skip(offset).Take(4).Aggregate<byte, ulong>(0, (current, item) => 256 * current + item);
    var fractionPart = data.Skip(offset + 4).Take(4).Aggregate<byte, ulong>(0, (current, item) => 256 * current + item);

    var milliseconds = intPart * 1000 + (fractionPart * 1000) / 0x100000000;

    return milliseconds;
}

private static DateTimeOffset ComputeDate(ulong milliseconds)
{
    // 1900年1月1日から、指定したミリ秒分足して時刻に変換
    var time = new DateTimeOffset(1900, 1, 1, 0, 0, 0, TimeSpan.FromHours(0));
    var result = (time + TimeSpan.FromMilliseconds(milliseconds)).ToLocalTime();

    return result;
}

サーバーから受けるデータで大事なのは、サーバーから返る時刻の情報です。得られたバイト配列の32バイト目から64ビット分がクライアントからの要求を受け取ったサーバー時間、40バイト目から64ビット分がサーバーからクライアントへ応答したサーバー時間です。これを受け取って、補正すると正しい時間として同期が完了するという感じです。


うまくいくとこんな感じ
f:id:ChiiAyano:20150510222303p:plain
エミュレーターだとすぐ時間ずれるんで、結構わかりやすいです


これで仮にシステム時刻を変えたりする場合、正確な時刻と大きな差があったりする場合は、いきなりその時刻にせず徐々に近づけていくなどをおこなうのが正しい実装らしいです。ただ表示するだけのようなケースの場合は特にそのへん気にしなくて良いと思います。