如何让基于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
相关产品推荐
相关产品推荐

