Win IoT/RT で StreamSocketListener を利用して簡易WebServerを作る

Win IoT on RPi では、HttpListener がありません。と言いますか、Universal アプリ自体に、System.Net.HttpListener がないので、何か代替案を考えないといけません。ってことで、すっかり忘れていた、Windows.Networking.StreamSocketListener を使うわけですが、ちょっと嵌ったのでメモを残しておきます。

StreamSocketListener をストアアプリで使う

もともと、(確か)Windows 8.1 で導入されてストアプリでも HTTP プロトコル待ちのサーバーが作れたところからスタートです。以前、HttpListener と TcpListener の違いと Firewall と netsh の設定 | Moonmile Solutions Blog; なところで、HttpListener を駆使していたのですが(この記事を書いたのは Windows 8 の頃)、netsh やら firewall やらの設定が大変で、どうにかならないかと思っていたところなのですが、Windows 8.1 ではあっさり解決されてしまいました。

でもって、当時のブログやら調べ直していたところ、Stack Overflow などを見ると、なかなかうまくいかないひとが多いようです。サンプルコードてきには、

Windows 8 StreamSocket sample サンプル 言語: C#, C++, JavaScript Visual Studio 2013 用
https://code.msdn.microsoft.com/windowsapps/StreamSocket-Sample-8c573931

が唯一正しいような気がします。本家にあたりましょう。とはいえ、この本家のサンプルにも落ち度があって、StreamSocketListener を使って HTTP プロトコル待ちをしているところは書かれていないんですよね。どちらかというと TcpListener に近いところにあるので、TCP/IP のデータを生で扱うようなものです。が、通常の Web API 的に使おうと思うとそのままでは結構面倒なので、HTTP プロトコル風にできると便利ですね。

幸いにして、Win IoT のサンプルの中に

App2App WebServer
https://github.com/ms-iot/samples/tree/develop/App2App%20WebServer

が、HTTP プロトコルを簡易実装しているので抜き出してみました。
ちなみに、Windows.Networking.StreamSocketListener 自体は、WinRT 内のクラスなのでデスクトップアプリから直接使えません(間接的に WinRT を参照設定させることで利用できますが)。デスクトップアプリの場合は、従来通り、HttpListener か TcpListener を使うとよいでしょう。

簡易的な WebServer クラスを実装する

ざっと実装したものが以下です。GET コマンドにしか対応していませんが、Web API で REST を使う限りはこれで十分です。まあ、そのうち画像を送る程度には POST に対応したいところですが。

/// <summary>
/// 簡易WebServerクラス
/// 単純な GET コマンドのみ対応する
/// </summary>
public class SimpleWebServer
{
    StreamSocketListener listener;
    StreamSocket socket;
    public event Action<string> OnReceived;

    // public Windows.Networking.HostName LOCALHOST { get; set; }
    public int PORT { get; set; }
    public SimpleWebServer()
    {
        // this.LOCALHOST = NetworkInformation.GetHostNames().Where(n => n.Type == Windows.Networking.HostNameType.Ipv4).First();
        // this.LOCALHOST = new Windows.Networking.HostName("localhost");
        this.PORT = 8080;
    }

    /// <summary>
    /// 受付開始
    /// </summary>
    public async void Start()
    {
        listener = new StreamSocketListener();
        listener.ConnectionReceived += Listener_ConnectionReceived;
        // await listener.BindEndpointAsync(LOCALHOST, PORT.ToString());
        await listener.BindServiceNameAsync(PORT.ToString());
    }
    private async void Listener_ConnectionReceived(StreamSocketListener sender, StreamSocketListenerConnectionReceivedEventArgs args)
    {
        socket = args.Socket;
        var dr = new DataReader(socket.InputStream);

        /// GET ヘッダを取り出し
        StringBuilder request = new StringBuilder();
        uint BufferSize = 1024;
        using (IInputStream input = socket.InputStream)
        {
            byte[] data = new byte[BufferSize];
            IBuffer buffer = data.AsBuffer();
            uint dataRead = BufferSize;
            while (dataRead == BufferSize)
            {
                await input.ReadAsync(buffer, BufferSize, InputStreamOptions.Partial);
                request.Append(Encoding.UTF8.GetString(data, 0, data.Length));
                dataRead = buffer.Length;
            }
        }
        // GET method を取り出し
        string requestMethod = request.ToString().Split('n')[0];
        string[] requestParts = requestMethod.Split(' ');
        var text = requestParts[1];

        /// GETコマンドの受信イベント
        if (this.OnReceived != null)
        {
            OnReceived(text);
        }
    }
    /// <summary>
    /// レスポンスを返す
    /// </summary>
    /// <param name="text"></param>
    public async void SendResponse( string text )
    {
        if (socket == null) return;

        byte[] bodyArray = Encoding.UTF8.GetBytes(text);
        MemoryStream stream = new MemoryStream(bodyArray);
        string header = String.Format("HTTP/1.0 200 OKrn" +
                            "Content-Length: {0}rn" +
                            "Connection: closernrn",
                            stream.Length);
        var dw = new DataWriter(socket.OutputStream);
        dw.WriteString(header);
        dw.WriteString(text);
        await dw.StoreAsync();
    }
}

利用するときは、こんな感じです。クライアントから受信をしたときの OnReceived イベントで処理を行います。応答は、そのまま SendResponse メソッドを使います。このあたり、ストリームにしてもよいでしょう。

SimpleWebServer _server;
private void MainPage_Loaded(object sender, RoutedEventArgs e)
{
    this.textIP.Text = NetworkPresenter.GetCurrentIpv4Address();

    _server = new SimpleWebServer();
    _server.OnReceived += _server_OnReceived;
    _server.Start();
}

private async void _server_OnReceived(string data)
{
    // GET 受信
    await Dispatcher.RunAsync(Windows.UI.Core.CoreDispatcherPriority.Normal,
        () => { textGet.Text = data; });
    // 応答を送信
    _server.SendResponse("response " + data);
}

マニフェストを書き換える

現状 Visual Studio 2015 RC ではマニフェストファイル(Package.appxmanifest)の GUI が出て来ず、XML を直接編集することになります。
StreamSocketListener クラスで HTTP プロトコル待ちをさせたい場合は、internetClientServer(インターネット クライアント/サーバー)とprivateNetworkClientServer(プライベートネットワーク)を追加してください。これを書き忘れているブログが多くて、巷で「繋がらない」というのは、大方このせいです。

  <Capabilities>
    <Capability Name="internetClientServer" />
    <Capability Name="privateNetworkClientServer"/>
  </Capabilities>

これを設定しておくだけで、netsh やら firewall やらの設定をしなくて良くなります。多分、ストアアプリの範疇になるので、Windows 本体の TCP/IP のほうから切り離されている(WinRTで包まれている)のではないか、と想像します。

BindServiceNameAsync メソッドを使う

StreamSocketListener でサーバーを作るときに、どの URL とポートで待つのか?を指定するのですが、実際のところ、複数のアプリで同一ポートを共有できないので、実質ポートだけしてすれば ok です。また、BindEndpointAsync メソッドで、エンドポイントで指定される「ホスト名」を設定することができるのですが、あえて「localhost」のように同一マシン内に制限したり、「home-pc」のようにホスト名をわざわざ指定して外部アプリから使わせないようにしたりしない限りは、BindEndpointAsync メソッドを使う意味がありません。と言いますか、ここでホスト名を指定するのが結構面倒です。わざわざ名前を揃えるために IP アドレスを取ってきているものもあるのですが(stack overflow では大抵ここで嵌っています)、時に制限が必要ない場合はポート番号だけ指定する BindServiceNameAsync メソッドで十分です。このあたりは、先の MSDN のサンプルコードにも書いてあります。

IEから呼び出してみる

簡単な REST で呼び出せるようにしておくと、IE のようなブラウザからの操作が簡単にできます。

ブラウザで、アドレスを指定すると、

ストアアプリ側で受信できる

これは Windows 10 のユニバーサルアプリで作っていますが、ARM でコンパイルし直せばそのまま Windows IoT on RPi 上で動きます。このあたりは、UAP(Univesal Application Platform)の有利な点ですよね。タイトルが、for WiFi になっていますが、現在のところ Win IoT on RPi では WiFi のドングルは動作しません(リリース時には動作するとのこと)。現状では、有線 LAN オンリーですね。

という訳で、これで LAN 経由で Win IoT on RPi を制御する目途が立ったので、Raspberry Pi から GPIO で制御をする仕組みを入れていきます。ひとまず、先日買って Arduino で動かせるようになったロボットアームを動かす方を先にしようかなと。

カテゴリー: RaspberryPi, Win IoT, WinRT パーマリンク