Bootstrap

c#使用TCP协议在局域网中传输数据

现实中会遇到一种情况,需要从一台电脑上将文件转移到另一台电脑时,通常会选用网络进行传输,或者使用移动存储设备进行传输。但前者传输速度受限,后者需要跑来跑去非常得麻烦。

一般这种情况,两台电脑连接的都是同一个网络,处在同一个局域网中,如果使用局域网来传输文件,岂不是不会受到上述两种方式的限制,何不美哉。

收集资料后得知,TCP连接需要接收方启动TCP服务,监听发送过来的消息。

启动TCP服务器代码如下,其中TcpHelper.ReceiveDataInTcp方法用来处理接收到的请求,具体实现,接下来再说。

 Task.Run(async () =>
{
    try
    {
        //启动TCP监听
        TcpListener listener = new TcpListener(IPAddress.Any, 5000);
        listener.Start();
        while (true)
        {
            try
            {
                TcpClient client = await listener.AcceptTcpClientAsync();
                //处理接收到的请求
                TcpHelper.ReceiveDataInTcp(client);
            }
            catch (Exception ex)
            {

            }
        }
    }
    catch (Exception ex)
    {
        Debug.WriteLine($"启动TCP服务失败:" + ex.Message);
    }
});

通常使用TCP传输的数据有两种,第一种是传输一个字符串,另一个传输文件,为了区别这两种类型数据,我将发送的NetworkStream数据中的第一个字节作为传输的数据的类型标识符,1表示传输的是字符串,2表示传输的是文件。

以下为使用TCP发送字符串的代码,将要发送的NetworkStream类型数据的第一个字节设置为0x01,表示发送的数据类型为字符串,在发送之前需要先于目标计算机进行连接。

        /// <summary>
        /// 发送字符串
        /// 消息的第一个字节用来表示数据类型:0x01 表示字符串,0x02 表示文件
        /// </summary>
        /// <param name="serverIp">目标IP地址</param>
        /// <param name="serverPort">目标端口地址</param>
        /// <param name="message">要发送的消息</param>
        /// <returns></returns>
        public static async Task SendStringAsync(string serverIp, int serverPort, string message, TcpClient client)
        {
            using (client)
            {
                await client.ConnectAsync(serverIp, serverPort);
                using (NetworkStream networkStream = client.GetStream())
                {
                    byte[] dataType = new byte[] { 0x01 }; // 字符串类型
                    await networkStream.WriteAsync(dataType, 0, dataType.Length);

                    byte[] data = Encoding.UTF8.GetBytes(message);
                    await networkStream.WriteAsync(data, 0, data.Length);
                }
            }
        }

如果需要发送文件的话,步骤要比发送字符串麻烦许多。在发送的数据中我们不仅要让目标计算机知道我们发送的数据的类型,还要让目标计算机知道我们传输的文件的名称,所以我们需要把带后缀(拓展名)的文件名称一起写入到NetworkStream类型数据中进行传输。

如果只是将文件名称一起写入会导致出现一个问题,目标计算机在接收我方发送的信息时,由于不知道文件名称所占的长度,导致文件名称以及文件本身都无法正确地读取。因此在传输之前需要将文件名称的长度也一起进行传输。

将文件的长度写入到NetworkStream类型数据中,也会占用一定的长度,如果占用的长度不固定,也会导致目标计算机无法正确地读取数据。查询资料后得知,windows默认情况下一个文件的最大名称长度是256个字符,如果解开限制的话,长度可能会更长,因此只使用一个字节来保存文件名称的长度并不合适,于是便使用两个字节保存文件名称的长度,传输文件的代码如下:

/// <summary>
/// 发送文件
/// 消息的第一个字节用来表示数据类型:0x01 表示字符串,0x02 表示文件
/// </summary>
/// <param name="serverIp">目标IP地址</param>
/// <param name="serverPort">目标端口地址</param>
/// <param name="filePath">要发送的文件的地址</param>
/// <returns></returns>
public static async Task SendFileAsync(string serverIp, int serverPort, string filePath)
{
    int BufferSize = 2048;
    using (TcpClient client = new TcpClient())
    {
        await client.ConnectAsync(serverIp, serverPort);
        using (NetworkStream networkStream = client.GetStream())
        {
            //第一个字节用来表示要传递的数据类型
            byte[] dataType = new byte[] { 0x02 }; // 文件类型
            await networkStream.WriteAsync(dataType, 0, dataType.Length);

            //获取目标文件带后缀的名称
            string fileName = Path.GetFileName(filePath);
            byte[] fileNameInByte = Encoding.UTF8.GetBytes(fileName);

            // 保存目标文件名称的长度(用2个字节)
            ushort fileNameLength = (ushort)fileNameInByte.Length;
            byte[] fileNameLengthBytes = BitConverter.GetBytes(fileNameLength);
            if (BitConverter.IsLittleEndian)
            {
                Array.Reverse(fileNameLengthBytes);
            }
            // 将目标文件的名称长度写入到流中
            await networkStream.WriteAsync(fileNameLengthBytes, 0, fileNameLengthBytes.Length);

            //将文件的名称写入流中
            networkStream.Write(fileNameInByte, 0, fileNameInByte.Length);


            byte[] buffer = new byte[BufferSize];
            using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
            {
                int bytesRead;
                while ((bytesRead = await fileStream.ReadAsync(buffer, 0, BufferSize)) > 0)
                {
                    await networkStream.WriteAsync(buffer, 0, bytesRead);
                }
            }
        }
    }
}

现在我们已经可以发送数据了,接下来是接收数据以及处理接收数据的方法,即文章开头的ReceiveDataInTcp方法,代码如下。

        /// <summary>
        /// TCP接收信息
        /// </summary>
        /// <returns></returns>
        public static async Task ReceiveDataInTcp(TcpClient client)
        {
            await Task.Delay(0);//进入异步
            int BufferSize = 2048;
            try
            {
                using (client)
                using (NetworkStream networkStream = client.GetStream())
                {
                    //分配一个缓冲区 buffer 存储读取的数据
                    byte[] buffer = new byte[BufferSize];
                    int bytesRead = networkStream.Read(buffer, 0, 1);

                    if (bytesRead > 0)
                    {
                        //根据请求第一个字节判断是文件还是字符串数据
                        byte dataType = buffer[0];

                        if (dataType == 0x01) // 字符串
                        {
                            GetStringInTcp(networkStream);
                        }
                        else if (dataType == 0x02) // 文件
                        {
                            GetFileInTcp(client, networkStream);                            
                        }
                    }
                }
            }
            catch (Exception ex)
            {
                Debug.WriteLine("TCP接收数据时发生错误:" + ex.Message);
            }
        }

GetStringInTcp代码如下:

        /// <summary>
        ///从TCP请求中获取到字符串数据
        /// </summary>
        /// <param name="client"></param>
        /// <param name="networkStream"></param>
        /// <returns></returns>
        public static async Task GetStringInTcp(NetworkStream networkStream)
        {
            int BufferSize = 2048;
            try
            {
                //分配一个缓冲区 buffer 存储读取的数据
                byte[] buffer = new byte[BufferSize];
                int bytesRead = 0;//用来统计每次取到的数据长度
                StringBuilder receivedData = new StringBuilder();
                while ((bytesRead = networkStream.Read(buffer, 0, BufferSize)) > 0)
                {
                    receivedData.Append(Encoding.UTF8.GetString(buffer, 0, bytesRead));
                }
                Debug.WriteLine("Received string: " + receivedData.ToString());
            }
            catch (Exception ex)
            {
                Debug.WriteLine($"An error occurred: {ex.Message}");
            }
        }

GetFileInTcp代码如下:

        /// <summary>
        /// 从TCP请求中获取到文件
        /// </summary>
        /// <param name="client"></param>
        /// <param name="networkStream"></param>
        /// <returns></returns>
        public static async Task GetFileInTcp(TcpClient client, NetworkStream networkStream)
        {
            int BufferSize = 2048;
            try
            {
                byte[] buffer = new byte[BufferSize];
                int bytesRead;

                // 读取文件名长度(2个字节)
                byte[] fileNameLengthBytes = new byte[2];
                await networkStream.ReadAsync(fileNameLengthBytes, 0, 2);

                //这个属性返回一个布尔值,表示当前系统的字节序。如果返回 true,说明当前系统是小端字节序;如果返回 false,说明是大端字节序
                if (BitConverter.IsLittleEndian)
                {
                    //这段代码的作用是确保字节序正确。具体来说,它用于在需要时调整字节顺序,以确保在不同字节序的系统之间正确读取和解释文件名长度。
                    Array.Reverse(fileNameLengthBytes);
                }
                //保存 16 位(2 字节)无符号整数,值的范围为 0 到 65,535
                ushort fileNameLength = BitConverter.ToUInt16(fileNameLengthBytes, 0);

                // 读取文件名
                bytesRead = networkStream.Read(buffer, 0, fileNameLength);
                string fileName = Encoding.UTF8.GetString(buffer, 0, fileNameLength);

                //处理接收文件的名称
                int fileNameIndex = 1;
                //获取不包括扩展名(后缀)的文件名
                string fileNameWithoutExtension=Path.GetFileNameWithoutExtension(fileName);
                //获取文件的扩展名
                string fileExtension = Path.GetExtension(fileName);
                while (File.Exists(AppDomain.CurrentDomain.BaseDirectory + "downLoad\\" + fileName))
                {
                    fileName = fileNameWithoutExtension + $"{fileNameIndex}"+ fileExtension;
                    fileNameIndex++;
                }

                // 接收文件内容并保存
                using (FileStream fileStream = new FileStream(AppDomain.CurrentDomain.BaseDirectory + "downLoad\\" + fileName, FileMode.Create, FileAccess.Write))
                {
                    while ((bytesRead = networkStream.Read(buffer, 0, BufferSize)) > 0)
                    {
                        fileStream.Write(buffer, 0, bytesRead);
                    }
                }
                Debug.WriteLine($"File '{fileName}' received successfully.");
            }
            catch (Exception ex)
            {
                Debug.WriteLine($"An error occurred: {ex.Message}");
            }
        }

此时,我们已经能够正常地使用TCP在局域网中进行通信了,但是还不够完美。使用互联网传输文件的时候,我们能够看到文件的名称,大小以及下载的进度。
在上述的代码中,传输的数据中已经有文件的名称了,我们还需要在传输的数据中加上文件的大小。我在此使用long类型来保存一个文件大小的字节数,并添加到传输的数据中即可(由于修改了传输数据的结构,接收数据的时候也要做出对应的修改)。

               //将文件的大小写到流中
               FileInfo fileInfo = new FileInfo(filePath);
               //fileInfo.Length值类型是long,转换出来的byte[]长度与sizeOf(Long)一致
               byte[] fileLengthInByte =BitConverter.GetBytes(fileInfo.Length);
               networkStream.Write(fileLengthInByte, 0, fileLengthInByte.Length);

现在传递的数据中已经包含了文件的名称,文件大小以及文件本身,现在还需要获取到传输文件时的进度。为实现这个功能我有两个想法,一个是使用Progress类型,另一个是使用yield return。最终我选择了后者(yield return需要语言版本为c#10及以上)。
通过接收返回的IAsyncEnumerable类型值来更新进度,需要注意一点,要对返回数据的速度做出限制,如果返回数据过快,更新UI时会对UI线程造成巨大的压力,因此我在此使用了Stopwatch类型来记录运行的时间,每超过100ms才返回一次值。发送文件的代码如下:

     /// <summary>
     /// 测试带进度的发送文件
     /// </summary>
     /// <param name="serverIp"></param>
     /// <param name="serverPort"></param>
     /// <param name="filePath"></param>
     /// <returns></returns>
     public static async IAsyncEnumerable<long> SendFileAsyncWithProgress(string serverIp, int serverPort, string filePath)
     {
         int bufferSize = 2048;
         long count = 0;//用来保存已发送的总量
         var yieldStopwatch = Stopwatch.StartNew();//用来统计已执行的时间
         using (TcpClient client = new TcpClient())
         {
             await client.ConnectAsync(serverIp, serverPort);
             using (NetworkStream networkStream = client.GetStream())
             {
                 //第一个字节用来表示要传递的数据类型
                 byte[] dataType = new byte[] { 0x02 }; // 文件类型
                 await networkStream.WriteAsync(dataType, 0, dataType.Length);

                 //获取目标文件带后缀的名称
                 string fileName = Path.GetFileName(filePath);
                 byte[] fileNameInByte = Encoding.UTF8.GetBytes(fileName);

                 // 保存目标文件名称的长度(用2个字节)
                 ushort fileNameLength = (ushort)fileNameInByte.Length;
                 byte[] fileNameLengthBytes = BitConverter.GetBytes(fileNameLength);
                 if (BitConverter.IsLittleEndian)
                 {
                     Array.Reverse(fileNameLengthBytes);
                 }
                 // 将目标文件的名称长度写入到流中
                 await networkStream.WriteAsync(fileNameLengthBytes, 0, fileNameLengthBytes.Length);

                 //将文件的名称写入流中
                 networkStream.Write(fileNameInByte, 0, fileNameInByte.Length);

                 //将文件的大小写到流中
                 FileInfo fileInfo = new FileInfo(filePath);
                 //fileInfo.Length值类型是long,转换出来的byte[]长度与sizeOf(Long)一致
                 byte[] fileLengthInByte =BitConverter.GetBytes(fileInfo.Length);
                 networkStream.Write(fileLengthInByte, 0, fileLengthInByte.Length);

                 //发送文件
                 byte[] buffer = new byte[bufferSize];
                 using (FileStream fileStream = new FileStream(filePath, FileMode.Open, FileAccess.Read))
                 {
                     int bytesRead;
                     while ((bytesRead = await fileStream.ReadAsync(buffer, 0, bufferSize)) > 0)
                     {
                         await networkStream.WriteAsync(buffer, 0, bytesRead);
                         count += bytesRead;
                         //每100ms返回一次进度
                         if (yieldStopwatch.ElapsedMilliseconds >= 100)
                         {
                             //重置计时器
                             yieldStopwatch.Restart();
                             yield return count;
                         }
                     }
                 }
             }
         }
         //完成就返回-1
         yield return -1;
     }

接收文件的代码如下(建议将获取文件名称,文件大小以及进度的功能分别拆分成3个方法)。

        /// <summary>
        /// 带进度的接收文件
        /// </summary>
        /// <param name="networkStream"></param>
        /// <returns></returns>
        public static async IAsyncEnumerable<long> GetFileInTcpWithProgress(NetworkStream networkStream)
        {

            var yieldStopwatch = Stopwatch.StartNew();//用来统计已执行的时间
            long count = 0;//用来保存已接收的总量
            int BufferSize = 2048;

            byte[] buffer = new byte[BufferSize];
            int bytesRead;

            // 读取文件名长度(2个字节)
            byte[] fileNameLengthBytes = new byte[2];
            await networkStream.ReadAsync(fileNameLengthBytes, 0, 2);

            //这个属性返回一个布尔值,表示当前系统的字节序。如果返回 true,说明当前系统是小端字节序;如果返回 false,说明是大端字节序
            if (BitConverter.IsLittleEndian)
            {
                //这段代码的作用是确保字节序正确。具体来说,它用于在需要时调整字节顺序,以确保在不同字节序的系统之间正确读取和解释文件名长度。
                Array.Reverse(fileNameLengthBytes);
            }
            //保存 16 位(2 字节)无符号整数,值的范围为 0 到 65,535
            ushort fileNameLength = BitConverter.ToUInt16(fileNameLengthBytes, 0);

            // 读取文件名
            bytesRead = networkStream.Read(buffer, 0, fileNameLength);
            string fileName = Encoding.UTF8.GetString(buffer, 0, fileNameLength);

            //处理接收文件的名称
            int fileNameIndex = 1;
            //获取不包括扩展名(后缀)的文件名
            string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(fileName);
            //获取文件的扩展名
            string fileExtension = Path.GetExtension(fileName);
            while (File.Exists(AppDomain.CurrentDomain.BaseDirectory + "downLoad\\" + fileName))
            {
                fileName = fileNameWithoutExtension + $"{fileNameIndex}" + fileExtension;
                fileNameIndex++;
            }

            // 接收文件内容并保存
            using (FileStream fileStream = new FileStream(AppDomain.CurrentDomain.BaseDirectory + "downLoad\\" + fileName, FileMode.Create, FileAccess.Write))
            {
                while ((bytesRead = networkStream.Read(buffer, 0, BufferSize)) > 0)
                {
                    count += bytesRead;
                    fileStream.Write(buffer, 0, bytesRead);
                    //每隔100ms返回一次进度
                    if (yieldStopwatch.ElapsedMilliseconds >= 100)
                    {
                        yield return count;
                        yieldStopwatch.Restart();
                    }
                }
                //接收完成就返回-1
                yield return -1;
            }
            Debug.WriteLine($"File '{fileName}' received successfully.");
        }

如果使用图形化界面,可以使用UDP广播发送自己的IP地址以及正在监听的端口,让其他计算机能够发现当前计算机,于是就可以不用手动填写IP地址和端口号。

;