SignalR ユーザーを接続にマッピングする

作成者: Tom FitzMacken

警告

このドキュメントは、最新版の SignalR を対象としていません。 ASP.NET Core SignalR に関する記事を参照してください。

このトピックでは、ユーザーとその接続に関する情報を保持する方法について説明します。

このトピックの作成には、Patrick Fletcher の協力を得ました。

このトピックで使用されるソフトウェアのバージョン

このトピックの以前のバージョン

SignalR の以前のバージョンの詳細については、「SignalR の以前のバージョン」を参照してください。

質問とコメント

このチュートリアルの感想、改善に関するフィードバックをページの下部にあるコメント欄にお寄せください。 チュートリアルに直接関連しない質問がある場合は、ASP.NET SignalR フォーラムまたは StackOverflow.com に投稿できます。

はじめに

ハブに接続する各クライアントは、一意の接続 ID を渡します。この値は、ハブ コンテキストの Context.ConnectionId プロパティで取得できます。 アプリケーションでユーザーを接続 ID にマップし、そのマッピングを保持する必要がある場合は、次のいずれかを使用できます。

これらの実装のそれぞれについて、このトピックで説明します。 Hub クラスの OnConnectedOnDisconnected、および OnReconnected メソッドを使用して、ユーザー接続の状態を追跡します。

アプリケーションに最適な方法は、次によって異なります。

  • アプリケーションをホストしている Web サーバーの数。
  • 現在接続されているユーザーの一覧を取得する必要があるかどうか。
  • アプリケーションまたはサーバーの再起動時にグループとユーザーの情報を保持する必要があるかどうか。
  • 外部サーバーの呼び出しの待機時間が問題かどうか。

次の表に、これらの考慮事項に適した方法を示します。

説明 複数台のサーバー 現在接続されているユーザーの一覧を取得する 再起動後に情報を保持する 最適なパフォーマンス
UserID プロバイダー
メモリ内
単一ユーザー グループ
永続的、外部

IUserID プロバイダー

この機能を使用すると、ユーザーは新しいインターフェイス IUserIdProvider を介して、IRequest に基づく userId を指定できます。

IUserIdProvider

public interface IUserIdProvider
{
    string GetUserId(IRequest request);
}

既定では、ユーザーの IPrincipal.Identity.Name をユーザー名として使用する実装があります。 これを変更するには、アプリケーションの起動時に IUserIdProvider の実装をグローバル ホストに登録します。

GlobalHost.DependencyResolver.Register(typeof(IUserIdProvider), () => new MyIdProvider());

ハブ内から、次の API を使用してこれらのユーザーにメッセージを送信できます。

特定のユーザーへのメッセージの送信

public class MyHub : Hub
{
    public void Send(string userId, string message)
    {
        Clients.User(userId).send(message);
    }
}

メモリ内ストレージ

次の例は、メモリに格納されているディクショナリに接続情報とユーザー情報を保持する方法を示しています。 ディクショナリは、HashSet を使用して接続 ID を格納します。ユーザーはいつでも SignalR アプリケーションへの複数の接続を持つことができます。 たとえば、複数のデバイスまたは複数のブラウザー タブを介して接続されているユーザーは、複数の接続 ID を持つことになります。

アプリケーションがシャットダウンすると、すべての情報が失われますが、ユーザーが接続を再確立すると、再入力されます。 環境に複数台の Web サーバーが含まれている場合、各サーバーには個別の接続のコレクションがあるため、メモリ内ストレージは機能しません。

最初の例は、ユーザーと接続のマッピングを管理するクラスを示しています。 HashSet のキーは、ユーザーの名前になります。

using System.Collections.Generic;
using System.Linq;

namespace BasicChat
{
    public class ConnectionMapping<T>
    {
        private readonly Dictionary<T, HashSet<string>> _connections =
            new Dictionary<T, HashSet<string>>();

        public int Count
        {
            get
            {
                return _connections.Count;
            }
        }

        public void Add(T key, string connectionId)
        {
            lock (_connections)
            {
                HashSet<string> connections;
                if (!_connections.TryGetValue(key, out connections))
                {
                    connections = new HashSet<string>();
                    _connections.Add(key, connections);
                }

                lock (connections)
                {
                    connections.Add(connectionId);
                }
            }
        }

        public IEnumerable<string> GetConnections(T key)
        {
            HashSet<string> connections;
            if (_connections.TryGetValue(key, out connections))
            {
                return connections;
            }

            return Enumerable.Empty<string>();
        }

        public void Remove(T key, string connectionId)
        {
            lock (_connections)
            {
                HashSet<string> connections;
                if (!_connections.TryGetValue(key, out connections))
                {
                    return;
                }

                lock (connections)
                {
                    connections.Remove(connectionId);

                    if (connections.Count == 0)
                    {
                        _connections.Remove(key);
                    }
                }
            }
        }
    }
}

次の例では、ハブから接続マッピング クラスを使用する方法を示します。 クラスのインスタンスは変数名 _connectionsに格納されます。

using System.Threading.Tasks;
using Microsoft.AspNet.SignalR;

namespace BasicChat
{
    [Authorize]
    public class ChatHub : Hub
    {
        private readonly static ConnectionMapping<string> _connections = 
            new ConnectionMapping<string>();

        public void SendChatMessage(string who, string message)
        {
            string name = Context.User.Identity.Name;

            foreach (var connectionId in _connections.GetConnections(who))
            {
                Clients.Client(connectionId).addChatMessage(name + ": " + message);
            }
        }

        public override Task OnConnected()
        {
            string name = Context.User.Identity.Name;

            _connections.Add(name, Context.ConnectionId);

            return base.OnConnected();
        }

        public override Task OnDisconnected(bool stopCalled)
        {
            string name = Context.User.Identity.Name;

            _connections.Remove(name, Context.ConnectionId);

            return base.OnDisconnected(stopCalled);
        }

        public override Task OnReconnected()
        {
            string name = Context.User.Identity.Name;

            if (!_connections.GetConnections(name).Contains(Context.ConnectionId))
            {
                _connections.Add(name, Context.ConnectionId);
            }

            return base.OnReconnected();
        }
    }
}

単一ユーザー グループ

ユーザーごとにグループを作成し、そのユーザーのみに連絡する場合にそのグループにメッセージを送信できます。 各グループの名前は、ユーザーの名前です。 ユーザーが複数の接続を持っている場合、各接続 ID はユーザーのグループに追加されます。

ユーザーが接続を解除したときに、グループからそのユーザーを手動で削除しないでください。 このアクションは、SignalR フレームワークによって自動的に実行されます。

次の例では、単一ユーザー グループを実装する方法を示しています。

using Microsoft.AspNet.SignalR;
using System;
using System.Threading.Tasks;

namespace BasicChat
{
    [Authorize]
    public class ChatHub : Hub
    {
        public void SendChatMessage(string who, string message)
        {
            string name = Context.User.Identity.Name;

            Clients.Group(who).addChatMessage(name + ": " + message);
        }

        public override Task OnConnected()
        {
            string name = Context.User.Identity.Name;

            Groups.Add(Context.ConnectionId, name);

            return base.OnConnected();
        }
    }
}

永続的な外部ストレージ

このトピックでは、データベースまたは Azure テーブル ストレージを使用して接続情報を格納する方法について説明します。 この方法は、各 Web サーバーが同じデータ リポジトリとやりとりできるため、複数の Web サーバーがある場合に機能します。 Web サーバーが動作を停止した場合、またはアプリケーションが再起動した場合、OnDisconnected メソッドは呼び出されません。 そのため、データ リポジトリに、有効でなくなった接続 ID のレコードが含まれる可能性があります。 これらの孤立したレコードをクリーンアップするには、アプリケーションに関連する期間外に作成された接続を無効にできます。 このセクションの例には、接続の作成時に追跡するための値が含まれていますが、バックグラウンド プロセスとして実行する場合があるため、古いレコードをクリーンアップする方法は示されていません。

データベース

次の例は、接続とユーザー情報を 1 つのデータベースに保持する方法を示しています。 任意のデータ アクセス テクノロジを使用できます。ただし、次の例は、Entity Framework を使用してモデルを定義する方法を示しています。 これらのエンティティ モデルは、データベース テーブルおよびフィールドに対応しています。 データ構造は、アプリケーションの要件によって大きく異なる場合があります。

最初の例では、多数の接続エンティティに関連付けることができるユーザー エンティティを定義する方法を示します。

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Data.Entity;

namespace MapUsersSample
{
    public class UserContext : DbContext
    {
        public DbSet<User> Users { get; set; }
        public DbSet<Connection> Connections { get; set; }
    }

    public class User
    {
        [Key]
        public string UserName { get; set; }
        public ICollection<Connection> Connections { get; set; }
    }

    public class Connection
    {
        public string ConnectionID { get; set; }
        public string UserAgent { get; set; }
        public bool Connected { get; set; }
    }
}

次に、ハブから、次に示すコードを使用して各接続の状態を追跡できます。

using System;
using System.Data.Entity;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Concurrent;
using Microsoft.AspNet.SignalR;

namespace MapUsersSample
{
    [Authorize]
    public class ChatHub : Hub
    {
        public void SendChatMessage(string who, string message)
        {
            var name = Context.User.Identity.Name;
            using (var db = new UserContext())
            {
                var user = db.Users.Find(who);
                if (user == null)
                {
                    Clients.Caller.showErrorMessage("Could not find that user.");
                }
                else
                {
                    db.Entry(user)
                        .Collection(u => u.Connections)
                        .Query()
                        .Where(c => c.Connected == true)
                        .Load();

                    if (user.Connections == null)
                    {
                        Clients.Caller.showErrorMessage("The user is no longer connected.");
                    }
                    else
                    {
                        foreach (var connection in user.Connections)
                        {
                            Clients.Client(connection.ConnectionID)
                                .addChatMessage(name + ": " + message);
                        }
                    }
                }
            }
        }

        public override Task OnConnected()
        {
            var name = Context.User.Identity.Name;
            using (var db = new UserContext())
            {
                var user = db.Users
                    .Include(u => u.Connections)
                    .SingleOrDefault(u => u.UserName == name);
                
                if (user == null)
                {
                    user = new User
                    {
                        UserName = name,
                        Connections = new List<Connection>()
                    };
                    db.Users.Add(user);
                }

                user.Connections.Add(new Connection
                {
                    ConnectionID = Context.ConnectionId,
                    UserAgent = Context.Request.Headers["User-Agent"],
                    Connected = true
                });
                db.SaveChanges();
            }
            return base.OnConnected();
        }

        public override Task OnDisconnected(bool stopCalled)
        {
            using (var db = new UserContext())
            {
                var connection = db.Connections.Find(Context.ConnectionId);
                connection.Connected = false;
                db.SaveChanges();
            }
            return base.OnDisconnected(stopCalled);
        }
    }
}

Azure Table Storage

次の Azure テーブル ストレージの例は、データベースの例に似ています。 Azure Table Storage Service の使用を開始するために必要なすべての情報が含まれているわけではありません。 詳細については、.NET からテーブル ストレージを使用する方法に関するページを参照してください。

次の例は、接続情報を格納するためのテーブル エンティティを示しています。 データはユーザー名でパーティション分割され、接続 ID によって各エンティティが識別されるため、ユーザーはいつでも複数の接続を持つことができます。

using Microsoft.WindowsAzure.Storage.Table;
using System;

namespace MapUsersSample
{
    public class ConnectionEntity : TableEntity
    {
        public ConnectionEntity() { }        

        public ConnectionEntity(string userName, string connectionID)
        {
            this.PartitionKey = userName;
            this.RowKey = connectionID;
        }
    }
}

ハブでは、各ユーザーの接続の状態を追跡します。

using Microsoft.AspNet.SignalR;
using Microsoft.WindowsAzure;
using Microsoft.WindowsAzure.Storage;
using Microsoft.WindowsAzure.Storage.Table;
using System;
using System.Linq;
using System.Threading.Tasks;

namespace MapUsersSample
{
    public class ChatHub : Hub
    {
        public void SendChatMessage(string who, string message)
        {
            var name = Context.User.Identity.Name;
            
            var table = GetConnectionTable();

            var query = new TableQuery<ConnectionEntity>()
                .Where(TableQuery.GenerateFilterCondition(
                "PartitionKey", 
                QueryComparisons.Equal, 
                who));

            var queryResult = table.ExecuteQuery(query).ToList();
            if (queryResult.Count == 0)
            {
                Clients.Caller.showErrorMessage("The user is no longer connected.");
            }
            else
            {
                foreach (var entity in queryResult)
                {
                    Clients.Client(entity.RowKey).addChatMessage(name + ": " + message);
                }
            }
        }

        public override Task OnConnected()
        {
            var name = Context.User.Identity.Name;
            var table = GetConnectionTable();
            table.CreateIfNotExists();

            var entity = new ConnectionEntity(
                name.ToLower(), 
                Context.ConnectionId);
            var insertOperation = TableOperation.InsertOrReplace(entity);
            table.Execute(insertOperation);
            
            return base.OnConnected();
        }

        public override Task OnDisconnected(bool stopCalled)
        {
            var name = Context.User.Identity.Name;
            var table = GetConnectionTable();

            var deleteOperation = TableOperation.Delete(
                new ConnectionEntity(name, Context.ConnectionId) { ETag = "*" });
            table.Execute(deleteOperation);

            return base.OnDisconnected(stopCalled);
        }

        private CloudTable GetConnectionTable()
        {
            var storageAccount =
                CloudStorageAccount.Parse(
                CloudConfigurationManager.GetSetting("StorageConnectionString"));
            var tableClient = storageAccount.CreateCloudTableClient();
            return tableClient.GetTableReference("connection");
        }
    }
}