温故之.NET Socket通信

上一篇文章介绍了内存映射文件,这篇文章我们介绍一种用得更加广泛的方式——Socket 通信

Socket 介绍

Socket 称为”套接字”,它分为流式套接字和用户数据报套接字,分别对应网络中的 TCP 和 UDP 协议。这两种均可以实现进程间通信(无论是否是同一机器)

TCP 协议是面向连接的协议,提供稳定的双向通信功能,TCP连接的建立是通过三次握手才能完成,稳定性高,创建连接的效率相对UDP较低

UDP协议是面向无连接的,效率高,但不保证数据一定能够正确传输(顺序、丢包等)

我们应该选择 UDP 还是 TCP?

  • 对数据的可靠性要求很高的场景,应该选择 TCP,比如涉及钱的地方。当然也可以选择 UDP,这时候需要我们自行来保证数据的可靠性
  • 对速度要求高,但允许数据出现少量错误的适合,UDP最合适。比如记录日志的场景:一台机器专用于记录日志,其他的机器将日志发送给这台机器即可;还有就是视频会议的场景

但实际项目中,这样“纯粹”的场景并不是那么多,因此,往往采用的方案都是 TCP、UDP 相结合的方式来实现。当然为了保证数据的可靠及业务的稳定性,很多框架都不仅仅只有这么两种技术

框架的复杂、轻量与否,与其应对的业务场景是相关的。我们需要根据不同的场景,来选择适合自己项目的框架。在 C# 中,有 FastSocketSuperSocketSocket 框架供大家选择。其中 SuperSocket 支持 IOCP,它可以实现高性能、高并发。其他语言有 NettyHP-Socket 等,这些也有 .NET 的移植版本

一般情况下,不建议各位朋友自己去写一个 Socket 框架来支持项目的业务场景,用现有的框架更加稳当。如果不知道选择什么框架,可以去 Github 上搜索相关的开源框架

选择 Github 中的框架时,我们应该注意

  • 选择 Star 最多的
  • 看作者上一次维护时间是多久,这个框架的 issue 多不多。更新频繁的,往往可以选择,这样遇到问题也可以及时的处理
  • 文档:有一个详细的开发文档,可以提高我们开发的速度

Socket 通信,是市面上很多框架的基础,因此我们有必要介绍下它的使用方式,及在开发过程中需要注意的事项

使用示例

在 C# 中,无论是 TCP 协议,还是 UDP 协议,都封装在了 Socket 这个类中。使用时,只需要我们指定不同的参数即可

TCP 与 UDP 区别

  • TCP 面向连接(如打电话要先拨号建立连接); UDP 是无连接的,即发送数据之前不需要建立连接(扔出去就不用管了)
  • TCP 提供可靠的服务。也就是说,通过 TCP 连接传送的数据,无差错,不丢失,不重复,且按序到达;UDP 尽最大努力交付,即不保证可靠交付
  • TCP 面向字节流,实际上是 TCP 把数据看成一连串无结构的字节流;UDP 是面向报文的
  • UDP 没有堵塞控制,因此网络出现堵塞不会使源主机的发送速率降低(对实时应用很有用,如IP电话,实时视频会议等)
  • 每一条 TCP 连接只能是点对点的;UDP 支持一对一,一对多,多对一和多对多的交互通信(群视频等场景)
  • TCP 首部开销 20 字节;UDP 的首部开销小,只有8个字节
  • TCP 的逻辑通信信道是全双工的可靠信道,UDP 则是不可靠信道

在大部分情况下(针对性能而言),我们无法感觉到这两者之间的差异;而在高并发的场景下,我们就能很容易体会到(因为访问量大了之后,任何细小的变化都能累积起来从而造成巨大的影响)

使用 TCP 面临的一个主要问题就是粘包,业界主流的解决方案可归纳如下

  • 消息定长:如每个数据包的大小固定为 1024 字节,如果不足 1024 字节,使用空格填充剩下的部分
  • 在包尾增加回车换行符进行分隔,比如 FTP 协议
  • 将消息分为消息头、消息体。消息头包含了消息的总长度,及其他的一些元数据,消息体存储具体的数据包。一般地,消息头可以采用定长的方式,比如分配 40 个字节,其中16字节用于存放消息的长度信息,其余部分存放其他数据。
  • 自定义应用层协议:这种方式是为具体的业务场景而实现的,比如腾讯就有一套他们自己的通信框架

另外,如果觉得自定义协议太麻烦,我们也可以根据 MQTT 协议来写一套符合它的解决方案

针对 TCP 的使用,我们给出一个例子。其中我们采用 Jil 来实现序列化

/// <summary>
/// 传输使用的包
/// </summary>
public class Packet {
    public const int TYPE_LOGIN = 10001;
    public const int TYPE_MSG = 10000;
    public const int TYPE_LOGOUT = 10002;
    public const int TYPE_INVALID = 40000;

    /// <summary>
    /// 这个包的类型。在实际业务场景中,一般会使用 int、short 等来表示,而不是 enum
    /// </summary>
    public int Type { get; set; }
    /// <summary>
    /// 具体的业务数据
    /// </summary>
    public string Data { get; set; }
}

以下为服务端代码

using Jil;
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

namespace App {
    class Program {
        static void Main(string[] args) {
            TcpListener tcpListener = new TcpListener(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9999));
            tcpListener.Start();
            /// 此处仅仅用于处理客户端的连接
            /// 而不涉及具体的业务逻辑
            while (true) {
                TcpClient remoteClient = tcpListener.AcceptTcpClient();
                ClientPacketHandlers packetHandlers = new ClientPacketHandlers(remoteClient);
            }
        }

    }

    /// <summary>
    /// 将业务逻辑处理分开
    /// </summary>
    public class ClientPacketHandlers {
        Dictionary<int, Action<NetworkStream, string>> clientHandlers = new Dictionary<int, Action<NetworkStream, string>>();
        TcpClient remoteClient;
        NetworkStream stream;
        Task processTask;
        CancellationTokenSource cancellationTokenSource;

        public ClientPacketHandlers(TcpClient client) {
            this.remoteClient = client;
            this.stream = remoteClient.GetStream();

            // 这个可以通过配置文件来添加处理器
            clientHandlers.Add(Packet.TYPE_LOGIN, HandleLogin);
            clientHandlers.Add(Packet.TYPE_MSG, HandleMsg);
            clientHandlers.Add(Packet.TYPE_LOGOUT, HandleLogout);

            cancellationTokenSource = new CancellationTokenSource();

            // 为该客户端开辟一个 Task,用于与该客户端通信
            // 在高并发场景中,往往不会这样做。而是采用 IOCP 或者其他的高性能的方式
            // 为每个客户端开辟一个 Task 不合理,也很浪费系统资源(因为不是每个客户端都会频繁发送消息)
            processTask = Task.Run(() => {
                byte[] buffer = new byte[1024];
                while (true) {
                    int bytesRead = stream.Read(buffer, 0, 1024);
                    if (bytesRead > 0) {
                        byte[] realBytes = new byte[bytesRead];
                        Buffer.BlockCopy(buffer, 0, realBytes, 0, bytesRead);

                        Packet packet = JSON.Deserialize<Packet>(Encoding.UTF8.GetString(realBytes));
                        if (packet != null) {
                            if (clientHandlers.ContainsKey(packet.Type)) {
                                clientHandlers[packet.Type].Invoke(stream, packet.Data);
                            } else {
                                SendPacket(stream, new Packet() { Type = Packet.TYPE_INVALID, Data = "No handlers for your type" });
                            }
                        }
                    }

                    if (cancellationTokenSource == null || cancellationTokenSource.IsCancellationRequested) {
                        break;
                    }
                }
            }, cancellationTokenSource.Token);
        }

        public void HandleLogin(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_LOGIN, Data = $"Hello, {data}" });
        }

        public void HandleMsg(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_MSG, Data = $"Received Msg : {data}" });
        }

        public void HandleLogout(NetworkStream stream, string data) {
            if (stream == null || string.IsNullOrEmpty(data)) return;
            SendPacket(stream, new Packet() { Type = Packet.TYPE_LOGOUT, Data = $"Logout, {data}" });
            try {
                if (cancellationTokenSource != null) {
                    cancellationTokenSource.Cancel();
                    cancellationTokenSource.Dispose();
                }
            } catch (Exception e) {
            } finally {
                cancellationTokenSource = null;
            }
        }


        public void SendPacket(NetworkStream stream, Packet packet) {
            byte[] packetBytes = Encoding.UTF8.GetBytes(JSON.Serialize(packet));
            stream.Write(packetBytes, 0, packetBytes.Length);
        }
    }
}

以下为客户端代码

using Jil;
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;

namespace App {
    class Program {
        static void Main(string[] args) {
            TcpClient tcpClient = new TcpClient();
            tcpClient.Connect(new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9999));
            NetworkStream networkStream = tcpClient.GetStream();

            Task.Run(() => {
                byte[] buffer = new byte[1024];
                while (true) {
                    int bytesRead = networkStream.Read(buffer, 0, 1024);
                    if (bytesRead > 0) {
                        byte[] realBytes = new byte[bytesRead];
                        Buffer.BlockCopy(buffer, 0, realBytes, 0, bytesRead);

                        Packet packet = JSON.Deserialize<Packet>(Encoding.UTF8.GetString(realBytes));
                        if (packet != null) {
                            Console.WriteLine($"RECEIVED DATA: {packet.Data}");
                        }
                    }
                }
            });

            while (true) {
                string line = Console.ReadLine();
                string[] strs = line.Split(':');
                if(strs.Length >= 2) {
                    if(strs[0] == "login") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_LOGIN, Data = strs[1] });
                    } else if (strs[0] == "msg") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_MSG, Data = strs[1] });
                    } else if (strs[0] == "logout") {
                        SendPacket(networkStream, new Packet() { Type = Packet.TYPE_LOGOUT, Data = strs[1] });
                    }
                }
            }
        }

        private static void SendPacket(NetworkStream networkStream, Packet packet) {
            byte[] packetBytes = Encoding.UTF8.GetBytes(JSON.Serialize(packet));
            networkStream.Write(packetBytes, 0, packetBytes.Length);
        }
    }
}

这便是 TCP 通信的基础示例了,在更复杂的场景中,系统的设计将会更加复杂。但宗旨都只有一个,提供更加稳定可靠的服务

UDP 的使用与 TCP 类似,因此就不一一举例了

开发建议

  • 尽量将对客户端的管理,与具体的业务逻辑分开,这样可以提高系统的可维护性
  • 如果使用 TCP,除了解决粘包之外,还需要使用心跳包来使连接处于活动状态
  • 在使用 UDP 的时候,如果需要保证数据的可靠性,此时需要通过其他的方式来辅助
  • 如果要采用 GitHub 上的一些框架,一定要参考前面给出的建议
  • 在不增加系统复杂度的情况下,可以使用微服务来提升系统的扩展性。但切记不可滥用,过多的微服务会造成系统的可维护性下降,并且是指数级的下降
  • 在高并发、高性能的场景下,需要采用其他的方式。比如 IOCP 等框架。除了避免系统资源的浪费,更是为了提升系统的响应能力



至此,这篇文章的内容讲解完毕。欢迎关注公众号【嘿嘿的学习日记】,所有的文章,都会在公众号首发,Thank you~

公众号二维码

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352

推荐阅读更多精彩内容