ASP.NET Core SignalR でルーム付きチャットアプリを作ってみた

最近SignalRが気になったので公式チュートリアルやってみました。
分かりやすくていいんだけどリアルタイムアプリにありがちなチャットルーム毎に通信を分ける機能がなかったので自分で実装してみました。
余計な機能はつけずに、最低限ルーム内通信が成り立つ程度のシンプルな実装にしています。
尚、ルーム内通信を実現するのにSignalRのグループ機能を利用しています。
実装済みのコードはGitHubに上げてます。

バージョン

  •  Visual Studio 2019
  • .NET 5.0

プロジェクト作成

基本公式チュートリアル 通りです。

  • Visual Studioで新しいプロジェクトを作成する
  • 「ASP.NET Core Webアプリ」を選択する
    • ※同じVS2019でもインストール環境によっては名称が少し違うことがあります。
  • プロジェクト名は「SignalRGroupChat」とした

とりあえず実行

プロジェクトが作成出来たら、まずは初期状態で動くことを確認するため「F5」キーを押して実行します。

SignalRクライアントライブラリを追加する

公式チュートリアル 通りです。

  • ソリューションエクスプローラーで、プロジェクトを右クリックし、[追加] -> [クライアント側のライブラリ]を選択する
  • [クライアント側のライブラリを追加します]ダイアログで以下の通り選択する
    • プロバイダー: unpkg
    • ライブラリ:@microsoft/signalr@latest
    • [特定のファイルの選択]を選択する
    • dist/browser フォルダを展開し、 signalr.jssignalr.min.js を選択する
    • [ターゲットロケーション]に wwwroot/js/signalr/ と入力する
    • [インストール]をクリック

SignalRハブを作成する

基本は公式チュートリアル 通りだけど、ハブに実装するコードについてはグループ機能に関する処理に書き換えます。

  • プロジェクト直下に Hubs フォルダを作成する
  • Hubs フォルダ内に ChatHub.cs を作成し、以下のように編集する
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;

namespace SignalRGroupChat.Hubs
{
    public class ChatHub : Hub
    {
        public async Task SendMessageToGroup(string group, string user, string message)
        {
            await Clients.Group(group).SendAsync("ReceiveMessage", user, message);
        }

        public async Task AddToGroup(string groupName)
        {
            await Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        }
    }
}
ちょっと説明
  • SendMessageToGroup 関数について
    • チュートリアルでは全クライアントに送信するが、今回は指定したグループに送信する(参考
  • AddToGroup 関数について
    • クライアントをグループに追加する処理(参考

SignalRを構成する

公式チュートリアル 通りです。

  • Startup.cs を以下のように変更する
using SignalRGroupChat.Hubs;
...
public void ConfigureServices(IServiceCollection services)
{
    services.AddRazorPages();
    services.AddSignalR();
}
...
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    ...

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapRazorPages();
        endpoints.MapHub<ChatHub>("/chatHub");
    });
}

SignalRクライアントコードを追加する

ここは公式チュートリアルとは少し異なります。
まず、トップページはグループ名を入力してもらう画面にします。
そして、チャットルームページを新たに作成してチャット画面を実装します。

  • Pages\Index.cshtml を以下のように変更する
@page
@model IndexModel
@{
    ViewData["Title"] = "Home page";
}

<div class="container">
    <div class="row">
        <div class="col-2">Room name</div>
        <div class="col-4"><input type="text" id="roomName" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="moveToRoom" value="Move to room" />
        </div>
    </div>
</div>
<script src="~/js/index.js"></script>
  • wwwroot\js\index.js を作成し、以下のように編集する
document.getElementById("moveToRoom").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    window.location.href = "Room/" + roomName;
});
  • Pages\Room.cshtml を作成し、以下のように編集する
    • VSを使っている場合はソリューションエクスプローラーで Pages フォルダを右クリックして[追加] -> [新しい項目] -> [Razorページ – 空]を選択すると良い(C#コードビハインドを自動生成してくれる)
@page "{roomname?}"
@model RoomModel
@{
    ViewData["Title"] = "Room";
}

<h1>@ViewData["Title"] : @Model.RoomName</h1>
<input type="hidden" id="roomName" value="@Model.RoomName" />
<div class="container">
    <div class="row"> </div>
    <div class="row">
        <div class="col-2">User</div>
        <div class="col-4"><input type="text" id="userInput" /></div>
    </div>
    <div class="row">
        <div class="col-2">Message</div>
        <div class="col-4"><input type="text" id="messageInput" /></div>
    </div>
    <div class="row"> </div>
    <div class="row">
        <div class="col-6">
            <input type="button" id="sendButton" value="Send Message" />
        </div>
    </div>
</div>
<div class="row">
    <div class="col-12">
        <hr />
    </div>
</div>
<div class="row">
    <div class="col-6">
        <ul id="messagesList"></ul>
    </div>
</div>
<script src="~/js/signalr/dist/browser/signalr.js"></script>
<script src="~/js/chat.js"></script>
  • Pages\Room.cshtml.cs を以下のように変更する
using Microsoft.AspNetCore.Mvc.RazorPages;

namespace SignalRGroupChat.Pages
{
    public class RoomModel : PageModel
    {
        public string RoomName;

        public void OnGet(string roomName)
        {
            RoomName = roomName;
        }
    }
}
ちょっと説明

ここにはURLの末尾をルーム名として設定する処理を書いています。

  • wwwroot\js\chat.js を作成し、以下のように編集する
"use strict";

var connection = new signalR.HubConnectionBuilder().withUrl("/chatHub").build();

document.getElementById("sendButton").disabled = true;

connection.on("ReceiveMessage", function (user, message) {
    var li = document.createElement("li");
    document.getElementById("messagesList").appendChild(li);
    li.textContent = `${user} says ${message}`;
});

connection.start().then(function () {
    document.getElementById("sendButton").disabled = false;

    var roomName = document.getElementById("roomName").value;
    connection.invoke("AddToGroup", roomName).catch(function (err) {
        return console.error(err.toString());
    });

}).catch(function (err) {
    return console.error(err.toString());
});

document.getElementById("sendButton").addEventListener("click", function (event) {
    var roomName = document.getElementById("roomName").value;
    var user = document.getElementById("userInput").value;
    var message = document.getElementById("messageInput").value;
    connection.invoke("SendMessageToGroup", roomName, user, message).catch(function (err) {
        return console.error(err.toString());
    });
    event.preventDefault();
});
ちょっと説明
  • chat.js は公式チュートリアルのコードを参考にしています。
  • 公式チュートリアルに追加したのは以下の2点です。
    • SignalR接続開始時にグループに参加する処理を追加しています。
    • 送信ボタンを押したときにグループを指定してチャットメッセージ送信するようにしています。

アプリを実行する

  • 「F5」キーを押して実行します。
  • ブラウザが起動します。
  • トップページでルーム名を入力するとルーム専用のチャットページに遷移します。
  • 下の画像のようにルームに分かれてメッセージをリアルタイムで送受信できます。
    • 上2つのブラウザウィンドウはRoom1に、下2つはRoom2に参加している、という想定です。
チャットアプリ実行画面
チャットアプリ実行画面

まとめと感想

公式チュートリアルのSignalRサンプルアプリにルーム別メッセージ送受信機能を追加しました。そのためにSignalRのグループの仕組みを使って実装しました。
実装して感じたのはSignalRは高水準ライブラリだということ。クライアントとサーバで情報を送信しあうというより、お互いの関数を実行させる、というイメージでプログラミングできました。
ネットワーク上の流れを意識せずにプログラムの関数レベルでプログラミングできるのは処理内容に集中できるので使いやすいですね。

AutoMapperで詰め替えるDTOクラスにはinitアクセサーが使えそう(C#)

プログラム内のオブジェクトを外部に転送するためにDTO (Data Transfer Object) を使うことがありますが、C#であればAutoMapperという便利なライブラリがあります。

これまでAutoMapperでDTOに詰め替えする際、DTOクラスはプロパティは①getアクセサーのみでコンストラクタで代入するのが一般的でした。もしくは簡単に②setアクセサーを設定する方法もありました。

それぞれの方法の特徴は以下の通りです。

①getアクセサーのみでコンストラクタで代入

  • DTO生成後に意図せず値を書き換えられない(良いこと)
  • コンストラクタを作るのが面倒

②setアクセサーを設定する

  • DTO生成後に値を書き換えられてしまう
  • コンストラクタを作らなくていいので楽

それぞれ良いところと悪いところがありましたが、C#9.0 の新機能に initアクセサーが登場したことでこの2つのいいとこどりができるようになりました。特徴をまとめておきます。

③initアクセサーを使う

  • DTO生成後に値を書き換えられない(良いこと)
  • コンストラクタが不要

参考のため各方法のサンプルコードを載せておきます。

サンプルコード

①getアクセサーのみでコンストラクタで代入

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public ItemDto(Item item) : this(item.Id, item.Name, item.Price)
        {
        }

        public ItemDto(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; }
        public int Price { get; }
    }
}

②setアクセサーを設定する

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public int Price { get; set; }
    }
}

③initアクセサーを使う

using AutoMapper;
using System;

namespace AutoMapperTest
{
    class Program
    {
        static void Main(string[] args)
        {
            var config = new MapperConfiguration(cfg => cfg.CreateMap<Item, ItemDto>());
            var mapper = config.CreateMapper();

            var item = new Item(1, "商品1", 100);
            ItemDto itemDto = mapper.Map<ItemDto>(item);

            Console.WriteLine("Item:");
            Console.WriteLine($"Id: {item.Id}, Name: {item.Name}, Price: {item.Price}");

            Console.WriteLine("ItemDto:");
            Console.WriteLine($"Id: {itemDto.Id}, Name: {itemDto.Name}, Price: {itemDto.Price}");

        }
    }

    class Item
    {
        public Item(int id, string name, int price)
        {
            Id = id;
            Name = name;
            Price = price;
        }

        public int Id { get; }
        public string Name { get; private set; }
        public int Price { get; private set; }
    }

    class ItemDto
    {
        public int Id { get; init; }
        public string Name { get; init; }
        public int Price { get; init; }
    }
}