SignalR 사용자를 연결에 매핑

Tom FitzMacken

경고

이 설명서는 최신 버전의 SignalR용이 아닙니다. ASP.NET Core SignalR을 살펴보세요.

이 항목에서는 사용자 및 해당 연결에 대한 정보를 유지하는 방법을 보여 줍니다.

패트릭 플레처는 이 주제를 쓰는 데 도움을 주었습니다.

이 항목에서 사용되는 소프트웨어 버전

이 항목의 이전 버전

이전 버전의 SignalR에 대한 자세한 내용은 SignalR 이전 버전을 참조하세요.

질문 및 의견

이 자습서를 어떻게 좋아했는지, 그리고 페이지 아래쪽의 메모에서 개선할 수 있는 사항에 대한 피드백을 남겨 주세요. 자습서와 직접 관련이 없는 질문이 있는 경우 ASP.NET SignalR 포럼 또는 StackOverflow.com 게시할 수 있습니다.

소개

허브에 연결하는 각 클라이언트는 고유한 연결 ID를 전달합니다. 허브 컨텍스트의 속성에서 Context.ConnectionId 이 값을 검색할 수 있습니다. 애플리케이션에서 사용자를 연결 ID에 매핑하고 해당 매핑을 유지해야 하는 경우 다음 중 하나를 사용할 수 있습니다.

이러한 각 구현은 이 항목에 나와 있습니다. 클래스의 OnConnected, OnDisconnectedOnReconnected 메서드를 Hub 사용하여 사용자 연결 상태 추적합니다.

애플리케이션에 가장 적합한 방법은 다음 사항에 따라 달라집니다.

  • 애플리케이션을 호스트하는 웹 서버 수입니다.
  • 현재 연결된 사용자 목록을 가져와야 하는지 여부입니다.
  • 애플리케이션 또는 서버가 다시 시작될 때 그룹 및 사용자 정보를 유지해야 하는지 여부입니다.
  • 외부 서버 호출 대기 시간이 문제인지 여부입니다.

다음 표에서는 이러한 고려 사항에 적합한 방법을 보여 줍니다.

고려 사항 둘 이상의 서버 현재 연결된 사용자 목록 가져오기 다시 시작한 후 정보 유지 최적의 성능
UserID 공급자
메모리 내
단일 사용자 그룹
영구, 외부

IUserID 공급자

이 기능을 사용하면 사용자가 새 인터페이스 IUserIdProvider를 통해 IRequest를 기반으로 하는 userId를 지정할 수 있습니다.

The 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를 갖습니다.

애플리케이션이 종료되면 모든 정보가 손실되지만 사용자가 연결을 다시 설정하면 다시 채워집니다. 각 서버에는 별도의 연결 컬렉션이 있기 때문에 환경에 둘 이상의 웹 서버가 포함된 경우 메모리 내 스토리지가 작동하지 않습니다.

첫 번째 예제에서는 연결에 대한 사용자 매핑을 관리하는 클래스를 보여 줍니다. 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);
                    }
                }
            }
        }
    }
}

다음 예제에서는 허브에서 연결 매핑 클래스를 사용하는 방법을 보여 줍니다. 클래스의 instance 변수 이름 _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 Table Storage를 사용하여 연결 정보를 저장하는 방법을 보여 줍니다. 이 방법은 각 웹 서버가 동일한 데이터 리포지토리와 상호 작용할 수 있으므로 여러 웹 서버가 있는 경우에 작동합니다. 웹 서버의 작동이 중지되거나 애플리케이션이 다시 시작되면 메서드가 OnDisconnected 호출되지 않습니다. 따라서 데이터 리포지토리에 더 이상 유효하지 않은 연결 ID에 대한 레코드가 있을 수 있습니다. 이러한 분리된 레코드를 클린 위해 애플리케이션과 관련된 시간 범위 외부에서 생성된 모든 연결을 무효화할 수 있습니다. 이 섹션의 예제에는 연결이 만들어진 시점을 추적하는 값이 포함되어 있지만 백그라운드 프로세스로 수행할 수 있으므로 이전 레코드를 클린 방법을 표시하지 않습니다.

데이터베이스

다음 예제에서는 데이터베이스에 연결 및 사용자 정보를 유지하는 방법을 보여 줍니다. 모든 데이터 액세스 기술을 사용할 수 있습니다. 그러나 아래 예제에서는 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 테이블 스토리지

다음 Azure Table Storage 예제는 데이터베이스 예제와 유사합니다. Azure Table Storage 서비스를 시작하는 데 필요한 모든 정보가 포함되지는 않습니다. 자세한 내용은 .NET에서 Table Storage를 사용하는 방법을 참조하세요.

다음 예제에서는 연결 정보를 저장하기 위한 테이블 엔터티를 보여줍니다. 데이터를 사용자 이름으로 분할하고 각 엔터티를 연결 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");
        }
    }
}