Azure App Service for Linux で .NET 6 の WebSocket を試してみた

個人的には WebSocket と言えば、チャットや複数人で同時編集するお絵描きツールなどで用いられる印象があります。例えば、サーバー側からクライアント側に随時データ更新を行う Web アプリなら何でもありかもしれません。以前 Azure App Service for Linux で Node.js の Socket.IO を使った、チャットのサンプルアプリを動かしてみた事があります。今回は .NET で WebSocket を試してみました。

## ローカルに .NET の Web アプリを作成

```bash:bash
prefix=mnrwsdn
region=japaneast

dotnet new webapp -o $prefix -f net6.0

cd $prefix

dotnet run
```

## ローカルで Web アプリを開いた状態

![appservice-websocket-dotnet-01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/fef15345-4fd9-ed49-6280-8329244c0998.png)

## Web アプリを WebSocket アプリにする

下記のドキュメントを参考に WebSocket 関連のコードを追加します。

https://github.com/Azure/app-service-linux-docs/blob/master/HowTo/WebSockets/use_websockets_with_dotnet.md

### wwwroot/index.html を作成

```html:wwwroot/index.html

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <title></title>
    <style>
        table {
            border: 0
        }

        .commslog-data {
            font-family: Consolas, Courier New, Courier, monospace;
        }

        .commslog-server {
            background-color: red;
            color: white
        }

        .commslog-client {
            background-color: green;
            color: white
        }
    </style>
</head>
<body>
    <h1>WebSocket Sample Application</h1>
    <p id="stateLabel">Ready to connect...</p>
    <div>
        <label for="connectionUrl">WebSocket Server URL:</label>
        <input id="connectionUrl" />
        <button id="connectButton" type="submit">Connect</button>
    </div>
    <p></p>
    <div>
        <label for="sendMessage">Message to send:</label>
        <input id="sendMessage" disabled />
        <button id="sendButton" type="submit" disabled>Send</button>
        <button id="closeButton" disabled>Close Socket</button>
    </div>

    <h2>Communication Log</h2>
    <table style="width: 800px">
        <thead>
            <tr>
                <td style="width: 100px">From</td>
                <td style="width: 100px">To</td>
                <td>Data</td>
            </tr>
        </thead>
        <tbody id="commsLog">
        </tbody>
    </table>

    <script>
        var connectionUrl = document.getElementById("connectionUrl");
        var connectButton = document.getElementById("connectButton");
        var stateLabel = document.getElementById("stateLabel");
        var sendMessage = document.getElementById("sendMessage");
        var sendButton = document.getElementById("sendButton");
        var commsLog = document.getElementById("commsLog");
        var closeButton = document.getElementById("closeButton");
        var socket;

        var scheme = document.location.protocol === "https:" ? "wss" : "ws";
        var port = document.location.port ? (":" + document.location.port) : "";

        connectionUrl.value = scheme + "://" + document.location.hostname + port + "/ws" ;

        function updateState() {
            function disable() {
                sendMessage.disabled = true;
                sendButton.disabled = true;
                closeButton.disabled = true;
            }
            function enable() {
                sendMessage.disabled = false;
                sendButton.disabled = false;
                closeButton.disabled = false;
            }

            connectionUrl.disabled = true;
            connectButton.disabled = true;

            if (!socket) {
                disable();
            } else {
                switch (socket.readyState) {
                    case WebSocket.CLOSED:
                        stateLabel.innerHTML = "Closed";
                        disable();
                        connectionUrl.disabled = false;
                        connectButton.disabled = false;
                        break;
                    case WebSocket.CLOSING:
                        stateLabel.innerHTML = "Closing...";
                        disable();
                        break;
                    case WebSocket.CONNECTING:
                        stateLabel.innerHTML = "Connecting...";
                        disable();
                        break;
                    case WebSocket.OPEN:
                        stateLabel.innerHTML = "Open";
                        enable();
                        break;
                    default:
                        stateLabel.innerHTML = "Unknown WebSocket State: " + htmlEscape(socket.readyState);
                        disable();
                        break;
                }
            }
        }

        closeButton.onclick = function () {
            if (!socket || socket.readyState !== WebSocket.OPEN) {
                alert("socket not connected");
            }
            socket.close(1000, "Closing from client");
        };

        sendButton.onclick = function () {
            if (!socket || socket.readyState !== WebSocket.OPEN) {
                alert("socket not connected");
            }
            var data = sendMessage.value;
            socket.send(data);
            commsLog.innerHTML += '<tr>' +
                '<td class="commslog-client">Client</td>' +
                '<td class="commslog-server">Server</td>' +
                '<td class="commslog-data">' + htmlEscape(data) + '</td></tr>';
        };

        connectButton.onclick = function() {
            stateLabel.innerHTML = "Connecting";
            socket = new WebSocket(connectionUrl.value);
            socket.onopen = function (event) {
                updateState();
                commsLog.innerHTML += '<tr>' +
                    '<td colspan="3" class="commslog-data">Connection opened</td>' +
                '</tr>';
            };
            socket.onclose = function (event) {
                updateState();
                commsLog.innerHTML += '<tr>' +
                    '<td colspan="3" class="commslog-data">Connection closed. Code: ' + htmlEscape(event.code) + '. Reason: ' + htmlEscape(event.reason) + '</td>' +
                '</tr>';
            };
            socket.onerror = updateState;
            socket.onmessage = function (event) {
                commsLog.innerHTML += '<tr>' +
                    '<td class="commslog-server">Server</td>' +
                    '<td class="commslog-client">Client</td>' +
                    '<td class="commslog-data">' + htmlEscape(event.data) + '</td></tr>';
            };
        };

        function htmlEscape(str) {
            return str.toString()
                .replace(/&/g, '&amp;')
                .replace(/"/g, '&quot;')
                .replace(/'/g, '&#39;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;');
        }
    </script>
</body>
</html>
```

### WebSocketController.cs を作成

```cs:WebSocketController.cs
using System.Net.WebSockets;
using Microsoft.AspNetCore.Mvc;

public class WebSocketController : ControllerBase
{
    [HttpGet("/ws")]
    public async Task Get()
    {
        if (HttpContext.WebSockets.IsWebSocketRequest)
        {
            using var webSocket = await HttpContext.WebSockets.AcceptWebSocketAsync();
            await Echo(webSocket);
        }
        else
        {
            HttpContext.Response.StatusCode = StatusCodes.Status400BadRequest;
        }
    }
    // </snippet>

    private static async Task Echo(WebSocket webSocket)
    {
        var buffer = new byte[1024 * 4];
        var receiveResult = await webSocket.ReceiveAsync(
            new ArraySegment<byte>(buffer), CancellationToken.None);

        while (!receiveResult.CloseStatus.HasValue)
        {
            await webSocket.SendAsync(
                new ArraySegment<byte>(buffer, 0, receiveResult.Count),
                receiveResult.MessageType,
                receiveResult.EndOfMessage,
                CancellationToken.None);

            receiveResult = await webSocket.ReceiveAsync(
                new ArraySegment<byte>(buffer), CancellationToken.None);
        }

        await webSocket.CloseAsync(
            receiveResult.CloseStatus.Value,
            receiveResult.CloseStatusDescription,
            CancellationToken.None);
    }
}
```

### BackgroundSocketProcessor.cs を作成

```cs:BackgroundSocketProcessor.cs
using System.Net.WebSockets;

internal class BackgroundSocketProcessor
{
    internal static void AddSocket(WebSocket webSocket, TaskCompletionSource<object> socketFinishedTcs) { }
}
```

### Startup.cs を作成

```cs:Startup.cs
using System.Net.WebSockets;


public static class Startup
{
    public static void UseWebSockets(WebApplication app)
    {
        // <snippet_UseWebSockets>
        app.UseWebSockets();
        // </snippet_UseWebSockets>
    }

    public static void AcceptWebSocketAsync(WebApplication app)
    {
        // <snippet_AcceptWebSocketAsync>
        app.Use(async (context, next) =>
        {
            if (context.Request.Path == "/ws")
            {
                if (context.WebSockets.IsWebSocketRequest)
                {
                    using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
                    await Echo(webSocket);
                }
                else
                {
                    context.Response.StatusCode = StatusCodes.Status400BadRequest;
                }
            }
            else
            {
                await next(context);
            }

        });
        // </snippet_AcceptWebSocketAsync>
    }

    public static void AcceptWebSocketAsyncBackgroundSocketProcessor(WebApplication app)
    {
        // <snippet_AcceptWebSocketAsyncBackgroundSocketProcessor>
        app.Run(async (context) =>
        {
            using var webSocket = await context.WebSockets.AcceptWebSocketAsync();
            var socketFinishedTcs = new TaskCompletionSource<object>();

            BackgroundSocketProcessor.AddSocket(webSocket, socketFinishedTcs);

            await socketFinishedTcs.Task;
        });
        // </snippet_AcceptWebSocketAsyncBackgroundSocketProcessor>
    }

    public static void UseWebSocketsOptionsAllowedOrigins(WebApplication app)
    {
        // <snippet_UseWebSocketsOptionsAllowedOrigins>
        var webSocketOptions = new WebSocketOptions
        {
            KeepAliveInterval = TimeSpan.FromMinutes(2)
        };

        webSocketOptions.AllowedOrigins.Add("https://client.com");
        webSocketOptions.AllowedOrigins.Add("https://www.client.com");

        app.UseWebSockets(webSocketOptions);
        // </snippet_UseWebSocketsOptionsAllowedOrigins>
    }

    // <snippet_Echo>
    private static async Task Echo(WebSocket webSocket)
    {
        var buffer = new byte[1024 * 4];
        var receiveResult = await webSocket.ReceiveAsync(
            new ArraySegment<byte>(buffer), CancellationToken.None);

        while (!receiveResult.CloseStatus.HasValue)
        {
            await webSocket.SendAsync(
                new ArraySegment<byte>(buffer, 0, receiveResult.Count),
                receiveResult.MessageType,
                receiveResult.EndOfMessage,
                CancellationToken.None);

            receiveResult = await webSocket.ReceiveAsync(
                new ArraySegment<byte>(buffer), CancellationToken.None);
        }

        await webSocket.CloseAsync(
            receiveResult.CloseStatus.Value,
            receiveResult.CloseStatusDescription,
            CancellationToken.None);
    }
    // </snippet_Echo>
}
```

### Program.cs を書き換え

下記の内容に丸ごと書き換えます。

```cs:Program.cs
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Hosting;


public class Program
{
    public static void Main(string[] args)
    {
    var builder = WebApplication.CreateBuilder(args);

    builder.Services.AddControllers();

    var app = builder.Build();

    // <snippet_UseWebSockets>
    var webSocketOptions = new WebSocketOptions
    {
        KeepAliveInterval = TimeSpan.FromMinutes(2)
    };

    app.UseWebSockets(webSocketOptions);
    // </snippet_UseWebSockets>

    app.UseDefaultFiles();
    app.UseStaticFiles();

    app.MapControllers();

    app.Run();
    }
}
```

## ローカルで WebSocket アプリの動作確認

```bash:bash
dotnet run
```

![appservice-websocket-dotnet-02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/4d1ff80d-fd2f-5014-bdc0-c37679102bff.png)

## App Service for Linux を作成

```bash:bash
az group create \
  --name ${prefix}-rg \
  --location $region

az webapp up \
  --name $prefix \
  --resource-group ${prefix}-rg \
  --location $region \
  --sku B1 \
  --os-type Linux
```

Windows は WebSocket を Enabled にする必要がありますが、Linux は不要でした。

## App Service for Linux 上で WebSocket の動作確認

![appservice-websocket-dotnet-03.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/675885/8abdfa82-8ffa-995b-ba44-8f83ff0a1248.png)

## 後片付け

```bash:bash
az group delete \
  --name ${prefix}-rg \
  --yes
```

## 参考

https://github.com/Azure/app-service-linux-docs/blob/master/HowTo/WebSockets/use_websockets_with_dotnet.md

https://learn.microsoft.com/ja-jp/cli/azure/webapp?view=azure-cli-latest#az-webapp-up