You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何让基于SignalR的C#/JS聊天应用支持外网客户端连接?

问题描述

我用C#(服务端)和JS(客户端)开发了一款基于SignalR的聊天应用,本地(localhost)运行完全正常,但现在想让外网客户端(比如国外的朋友)也能使用,不清楚实现方法和学习方向,恳请提供解决方案或学习资源。

以下是我的SignalR服务端代码:

namespace PmmTcpListener
{
    public class PmmSignalRServer : IDisposable
    {

        #region Private Properties

        /// <summary>
        /// To be set in <see cref="Dispose(bool)"/> method
        /// </summary>
        private bool _disposedValue;
        public string SignalRwebAddress = "https://localhost:5050";
        public bool IsStarted = false;


        public bool cIsStarted = false;
        public string cSignalRwebAddress = "https://localhost:5050/pmmchat";
        private string PrevMessage = "";
        private string CrntMessage = "";

        private string ClientUrl = "";


        #endregion


        #region Constructor

        /// <summary>
        /// The main constructor for the class
        /// Doing instantiation for Sql Commands based on database type <see cref="DbTypes"/> 
        /// By the way the default selected one is type <see cref="DbTypes.mysql"/>
        /// </summary>
        public PmmSignalRServer()
        {
            //Initialize the Sql Commands
            //Sqlcmd = new MySqlCommand();
            //AlarmsCommand = new MySqlCommand();
            //SqlServerCmd = new SqlCommand();
            //SqlServerAlarmsCommand = new SqlCommand();
            //SqliteCmd = new SQLiteCommand();
            //SqliteAlarmsCommand = new SQLiteCommand();
        }

        #endregion


        #region MainFunctions


        /// <summary>
        /// To be set 
        /// </summary>
        //public void Start(string SRport = "https://localhost:5050")
        //{
        //  CreateWebHostBuilder().Build().Run();
        //}
        //private static IWebHostBuilder CreateWebHostBuilder() => WebHost.CreateDefaultBuilder().UseStartup<iStartup>().UseUrls("https://localhost:5050");



        /// <summary>
        /// To be set 
        /// </summary>
        public void StartServe(string SRport = "http://localhost:5050")
        {
            SignalRwebAddress = SRport;
            IsStarted = true;

            Thread Serverthread = new Thread(StartSeerverThread);
            Serverthread.Start();

        }


        /// <summary>
        /// To be set 
        /// </summary>
        private void StartSeerverThread()
        {
            try
            {
                WebHost.CreateDefaultBuilder()
                    .ConfigureServices(services => services.AddSignalR())
                    .ConfigureServices(services => services.AddCors(options => options.AddPolicy("CorsPolicy",
                builder =>
                {
                    //builder.AllowAnyMethod().AllowAnyHeader()
                    //       .WithOrigins("http://localhost:59054")
                    //       .AllowCredentials();


                    builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader();
                })))

                    .Configure(app => app.UseRouting().UseCors("CorsPolicy").UseEndpoints(endpoints =>
                    {
                        endpoints.MapHub<ChatHub>("/PmmChat");
                    })
                    )

                    .UseUrls(SignalRwebAddress)
                    .ConfigureLogging(config =>
                    {
                        config.ClearProviders();
                    })
                    .Build()
                    .Run();
            }
            catch (Exception ex)
            {
                PmmGlobFunctions.TrackThis(" StartServerThread : " + ex.Message);
            }
        }


        /// <summary>
        /// To be set 
        /// </summary>
        public void StartClient(string hubUrl = "https://localhost:5050")
        {
            SignalRwebAddress = hubUrl;
            cIsStarted = true;

            //ClientUrl

            ClientUrl = SignalRwebAddress.Replace(@"*", "localhost");

            ClientUrl = ClientUrl + @"/pmmchat";


            Thread thread = new Thread(StartClientThread);
            thread.Start();
        }

        /// <summary>
        /// To be set 
        /// </summary>
        private void StartClientThread()
        {

            try
            {

                //IsStarted = true; .WithUrl(SignalRwebAddress+"/pmmchat")

                var connection = new HubConnectionBuilder().WithUrl(ClientUrl).Build();

                connection.StartAsync().Wait();

                connection.InvokeCoreAsync("SendMessage", args: new[] { "PMM Users" }); // join the main group "PMM Users"

                while (cIsStarted)
                {
                    if (CrntMessage != PrevMessage)
                    {
                        connection.InvokeCoreAsync("SendMessage", args: new[] { "PmmServer", CrntMessage });
                        PrevMessage = CrntMessage;
                    }
                }


                connection.DisposeAsync();

            }
            catch (Exception ex)
            {
                PmmGlobFunctions.TrackThis(" StartClientThread : " + ex.Message);
            }

        }

        /// <summary>
        /// To be set 
        /// </summary>
        public void StopClient()
        {
            cIsStarted = false;
        }

        /// <summary>
        /// To be set 
        /// </summary>
        public void SendFromServer(string message)
        {
            CrntMessage = message;
            //connection.On("ReceiveMessage", (string userName, string message) =>
            //{
            //    Console.WriteLine(userName + " : " + message);
            //});
        }



        #endregion


        #region Implement Dispose

        /// <summary>
        /// if <paramref name="disposing"/> = true, The dispose method has been called
        /// by our code. Managed and unmanaged objects can be disposed
        /// if <paramref name="disposing"/> = false, The dispose method has been called
        /// by runtime finalizer. Unmanaged objects only should be disposed.
        /// </summary>
        /// <param name="disposing">disposing value</param>
        protected virtual void Dispose(bool disposing)
        {
            if (!_disposedValue)
            {
                if (disposing)
                {
                    // Dispose any managed objects
                    // ...
                }

                // Now disposed of any unmanaged objects
                // ...

                _disposedValue = true;
            }
        }

        /// <summary>
        /// Dispose method which implemented by <see cref="IDisposable"/>
        /// </summary>
        public void Dispose()
        {
            // Calling override Dispose method to be used in implemented method
            Dispose(true);

            // Commented by Dr.Mahmoud
            //GC.SuppressFinalize(this);
        }

        #endregion 
    }

    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);
                    }
                }
            }
        }
    }
       
    public class ChatHub : Hub
    {

        // My Work --> Mohannad 
        private readonly static ConnectionMapping<string> _connections = new ConnectionMapping<string>();

        public override Task OnConnectedAsync()
        {
            Groups.AddToGroupAsync(Context.ConnectionId, Context.User.Identity.Name);
            return base.OnConnectedAsync();
        }

        public async Task SendMessageToClient(string user, string receiverConnectionId, string message)
        {
            foreach (var connectionId in _connections.GetConnections(user))
            {
                await Clients.Client(connectionId).SendAsync("ReceiveMessage", user, message);
            }
            // await Clients.Client(receiverConnectionId).SendAsync("ReceiveMessage", user, message);
        }

        public async Task SendMessageUsers(string user, string[] receiverUsersIds, string message)
        {
            // here you need to set the list of Users
            await Clients.Users(receiverUsersIds).SendAsync("ReceiveMessage", user, message);
        }

        public async Task SendMessageOthers(string user, string message)
        {
            await Clients.Others.SendAsync("ReceiveMessage", user, message);
        }


        public async Task SendMessageToUser(string user, string receiverConnectionId, string message)
        {
            await Clients.Client(receiverConnectionId).SendAsync("ReceiveMessage", user, message);
        }

        public string GetConnectionId() => Context.ConnectionId;


        //public override Task OnConnectedAsync()
        //{
        //    string name = Context.User.Identity.Name;
        //    _connections.Add(name, Context.ConnectionId);

        //    return base.OnConnectedAsync();
        //}


        //public override Task OnDisconnectedAsync(Exception exception)
        //{
        //    string name = Context.User.Identity.Name;

        //    _connections.Remove(name, Context.ConnectionId);

        //    return base.OnDisconnectedAsync(exception);
        //}


        // end Mohannad 

        public async Task SendMessage(string user, string message)
        {
            // await Clients.All.SendAsync("ReceiveMessage", user, PmmCommandReader.ReadCommand(message));
            await Clients.All.SendAsync("ReceiveMessage", user, message);
        }

        public Task SendMessageToCaller(string user, string message)
        {
            return Clients.Caller.SendAsync("ReceiveMessage", user, message);
        }

        public Task SendMessageToGroup(string user, string message)
        {
            return Clients.Group("PMM Users").SendAsync("ReceiveMessage", user, message);
        }

        public Task JoinGroup(string groupName)
        {
            return Groups.AddToGroupAsync(Context.ConnectionId, groupName);
        }

        public Task LeaveGroup(string groupName)
        {
            return Groups.RemoveFromGroupAsync(Context.ConnectionId, groupName);
        }

    }


    //End of NameSpace
}
解决方案与学习方向

一、核心实现步骤

1. 网络与部署配置

  • 端口转发(本地服务器):如果服务端在局域网内,登录路由器后台设置端口转发,将SignalR服务使用的端口(如代码中的5050)映射到你的公网IP对应端口。若使用HTTPS,确保443端口或自定义SSL端口正确转发。
  • 修改服务监听地址:不要仅监听localhost,改为0.0.0.0或你的公网IP,让服务接收所有网卡的外部请求。例如修改StartServe方法的默认参数:
public void StartServe(string SRport = "http://0.0.0.0:5050")
{
    SignalRwebAddress = SRport;
    IsStarted = true;

    Thread Serverthread = new Thread(StartSeerverThread);
    Serverthread.Start();
}
  • HTTPS配置:外网访问必须启用HTTPS,否则浏览器会限制WebSocket连接。可以使用免费的Let's Encrypt证书,或在部署时配置SSL证书,确保SignalR传输协议正常工作。
  • 防火墙放行:在服务端机器的防火墙中,允许SignalR端口(5050/443)的入站请求,避免外部连接被拦截。

2. CORS策略优化

你的代码中当前使用AllowAnyOrigin,生产环境建议指定允许的客户端域名,降低安全风险:

builder.WithOrigins("https://your-client-domain.com")
       .AllowAnyMethod()
       .AllowAnyHeader()
       .AllowCredentials();

注意:跨域场景下必须启用AllowCredentials,同时JS客户端连接时要添加withCredentials: true配置:

const connection = new signalR.HubConnectionBuilder()
    .withUrl("https://your-public-ip:5050/PmmChat", { withCredentials: true })
    .build();

3. 客户端连接地址更新

JS客户端不能再使用localhost,需替换为你的公网IP或域名,确保外网客户端能正确指向服务端。

二、代码优化建议

  • 避免硬编码:将服务地址、CORS允许域名等配置项移到appsettings.json,方便后续修改和部署。
  • 线程管理:当前使用Thread启动服务,建议改用IHostedService或异步启动方式,更符合ASP.NET Core的设计规范。

三、学习方向

  • SignalR部署文档:深入学习SignalR在IIS、Docker、云服务等环境的部署方式,掌握WebSocket、Server-Sent Events等传输协议的适配场景。
  • 网络基础:熟悉端口转发、NAT、HTTPS证书配置、防火墙规则等网络知识,能快速排查外网访问的常见问题(如连接超时、跨域错误)。
  • 云服务部署:若不想维护本地服务器,可将SignalR服务部署到Azure App Service、阿里云ECS等云平台,这些平台提供现成的公网访问、SSL配置和弹性扩容能力。

内容的提问来源于stack exchange,提问作者Mohannad Malkawi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 12:35:23