• C#网络编程(订立协议和发送文件) Part.4


    文件传输

    前面两篇文章所使用的范例都是传输字符串,有的时候我们可能会想在服务端和客户端之间传递文件。比如,考虑这样一种情况,假如客户端显示了一个菜单,当我们输入S1、S2或S3(S为Send缩写)时,分别向服务端发送文件Client01.jpg、Client02.jpg、Client03.jpg;当我们输入R1、R2或R3时(R为Receive缩写),则分别从服务端接收文件Server01.jpg、Server02.jpg、Server03.jpg。那么,我们该如何完成这件事呢?此时可能有这样两种做法:

    • 类似于FTP协议,服务端开辟两个端口,并持续对这两个端口侦听:一个用于接收字符串,类似于FTP的控制端口,它接收各种命令(接收或发送文件);一个用于传输数据,也就是发送和接收文件。
    • 服务端只开辟一个端口,用于接收字符串,我们称之为控制端口。当接到请求之后,根据请求内容在客户端开辟一个端口专用于文件传输,并在传输结束后关闭端口。

    现在我们只关注于上面的数据端口,回忆一下在第二篇中我们所总结的,可以得出:当我们使用上面的方法一时,服务端的数据端口可以为多个客户端的多次请求服务;当我们使用方法二时,服务端只为一个客户端的一次请求服务,但是因为每次请求都会重新开辟端口,所以实际上还是相当于可以为多个客户端的多次请求服务。同时,因为它只为一次请求服务,所以我们在数据端口上传输文件时无需采用异步传输方式。但在控制端口我们仍然需要使用异步方式。

    从上面看出,第一种方式要好得多,但是我们将采用第二种方式。至于原因,你可以回顾一下Part.1(基本概念和操作)中关于聊天程序模式的讲述,因为接下来一篇文章我们将创建一个聊天程序,而这个聊天程序采用第三种模式,所以本文的练习实际是对下一篇的一个铺垫。

    1.订立协议

    1.1发送文件

    我们先看一下发送文件的情况,如果我们想将文件client01.jpg由客户端发往客户端,那么流程是什么:

    1. 客户端开辟数据端口用于侦听,并获取端口号,假设为8005。
    2. 假设客户端输入了S1,则发送下面的控制字符串到服务端:[file=Client01.jpg, mode=send, port=8005]。
    3. 服务端收到以后,根据客户端ip和端口号与该客户端建立连接。
    4. 客户端侦听到服务端的连接,开始发送文件。
    5. 传送完毕后客户端、服务端分别关闭连接。

    此时,我们订立的发送文件协议为:[file=Client01.jpg, mode=send, port=8005]。但是,由于它是一个普通的字符串,在上一篇中,我们采用了正则表达式来获取其中的有效值,但这显然不是一种好办法。因此,在本文及下一篇文章中,我们采用一种新的方式来编写协议:XML。对于上面的语句,我们可以写成这样的XML:

    <protocol><file name="client01.jpg" mode="send" port="8005" /></protocol>

    这样我们在服务端就会好处理得多,接下来我们来看一下接收文件的流程及其协议。

    NOTE:这里说发送、接收文件是站在客户端的立场说的,当客户端发送文件时,对于服务器来收,则是接收文件。

    1.2接收文件

    接收文件与发送文件实际上完全类似,区别只是由客户端向网络流写入数据,还是由服务端向网络流写入数据。

    1. 客户端开辟数据端口用于侦听,假设为8006。
    2. 假设客户端输入了R1,则发送控制字符串:<protocol><file name="Server01.jpg" mode="receive" port="8006" /></protocol>到服务端。
    3. 服务端收到以后,根据客户端ip和端口号与该客户端建立连接。
    4. 客户端建立起与服务端的连接,服务端开始网络流中写入数据。
    5. 传送完毕后服务端、客户端分别关闭连接。

    2.协议处理类的实现

    和上面一章一样,在开始编写实际的服务端客户端代码之前,我们首先要编写处理协议的类,它需要提供这样两个功能:1、方便地帮我们获取完整的协议信息,因为前面我们说过,服务端可能将客户端的多次独立请求拆分或合并。比如,客户端连续发送了两条控制信息到服务端,而服务端将它们合并了,那么则需要先拆开再分别处理。2、方便地获取我们所想要的属性信息,因为协议是XML格式,所以还需要一个类专门对XML进行处理,获得字符串的属性值。

    2.1 ProtocalHandler辅助类

    我们先看下ProtocalHandler,它与上一篇中的RequestHandler作用相同。需要注意的是必须将它声明为实例的,而非静态的,这是因为每个TcpClient都需要对应一个ProtocalHandler,因为它内部维护的patialProtocal不能共享,在协议发送不完整的情况下,这个变量用于临时保存被截断的字符串。

    public class ProtocolHandler {

        private string partialProtocal; // 保存不完整的协议
       
        public ProtocolHandler() {
            partialProtocal = "";      
        }

        public string[] GetProtocol(string input) {
            return GetProtocol(input, null);
        }
       
        // 获得协议
        private string[] GetProtocol(string input, List<string> outputList) {
            if (outputList == null)
                outputList = new List<string>();

            if (String.IsNullOrEmpty(input))
                return outputList.ToArray();

            if (!String.IsNullOrEmpty(partialProtocal))
                input = partialProtocal + input;

            string pattern = "(^<protocol>.*?</protocol>)";

            // 如果有匹配,说明已经找到了,是完整的协议
            if (Regex.IsMatch(input, pattern)) {

                // 获取匹配的值
                string match = Regex.Match(input, pattern).Groups[0].Value;
                outputList.Add(match);
                partialProtocal = "";

                // 缩短input的长度
                input = input.Substring(match.Length);

                // 递归调用
                GetProtocol(input, outputList);

            } else {
                // 如果不匹配,说明协议的长度不够,
                // 那么先缓存,然后等待下一次请求
                partialProtocal = input;
            }

            return outputList.ToArray();
        }
    }

    因为现在它已经不是本文的重点了,所以我就不演示对于它的测试了,本文所附带的代码中含有它的测试代码(我在ProtocolHandler中添加了一个静态类Test())。

    2.2 FileRequestType枚举和FileProtocol结构

    因为XML是以字符串的形式在进行传输,为了方便使用,我们最好构建一个强类型来对它们进行操作,这样会方便很多。我们首先可以定义FileRequestMode枚举,它代表是发送还是接收文件:

    public enum FileRequestMode {
        Send = 0,
        Receive
    }

    接下来我们再定义一个FileProtocol结构,用来为整个协议字符串提供强类型的访问,注意这里覆盖了基类的ToString()方法,这样在客户端我们就不需要再手工去编写XML,只要在结构值上调用ToString()就OK了,会方便很多。

    public struct FileProtocol {
        private readonly FileRequestMode mode;
        private readonly int port;
        private readonly string fileName;

        public FileProtocol
            (FileRequestMode mode, int port, string fileName) {
            this.mode = mode;
            this.port = port;
            this.fileName = fileName;
        }

        public FileRequestMode Mode {
            get { return mode; }
        }

        public int Port {
            get { return port; }
        }

        public string FileName {
            get { return fileName; }
        }

        public override string ToString() {
            return String.Format("<protocol><file name=\"{0}\" mode=\"{1}\" port=\"{2}\" /></protocol>", fileName, mode, port);
        }
    }

    2.3 ProtocolHelper辅助类

    这个类专用于将XML格式的协议映射为我们上面定义的强类型对象,这里我没有加入try/catch异常处理,因为协议对用户来说是不可见的,而且客户端应该总是发送正确的协议,我觉得这样可以让代码更加清晰:

    public class ProtocolHelper {

        private XmlNode fileNode;
        private XmlNode root;
       
        public ProtocolHelper(string protocol) {
            XmlDocument doc = new XmlDocument();
            doc.LoadXml(protocol);
            root = doc.DocumentElement;
            fileNode = root.SelectSingleNode("file");
        }

        // 此时的protocal一定为单条完整protocal
        private FileRequestMode GetFileMode() {
            string mode = fileNode.Attributes["mode"].Value;
            mode = mode.ToLower();
            if (mode == "send")
                return FileRequestMode.Send;
            else
                return FileRequestMode.Receive;
        }

        // 获取单条协议包含的信息
        public FileProtocol GetProtocol() {
            FileRequestMode mode = GetFileMode();
            string fileName = "";
            int port = 0;

            fileName = fileNode.Attributes["name"].Value;
            port = Convert.ToInt32(fileNode.Attributes["port"].Value);

            return new FileProtocol(mode, port, fileName);
        }
    }

    OK,我们又耽误了点时间,下面就让我们进入正题吧。

    3.客户端发送数据

    3.1 服务端的实现

    我们还是将一个问题分成两部分来处理,先是发送数据,然后是接收数据。我们先看发送数据部分的服务端。如果你从第一篇文章看到了现在,那么我觉得更多的不是技术上的问题而是思路,所以我们不再将重点放到代码上,这些应该很容易就看懂了。

    class Server {
        static void Main(string[] args) {
            Console.WriteLine("Server is running ... ");
            IPAddress ip = IPAddress.Parse("127.0.0.1");
            TcpListener listener = new TcpListener(ip, 8500);

            listener.Start();           // 开启对控制端口 8500 的侦听
            Console.WriteLine("Start Listening ...");

            while (true) {
                // 获取一个连接,同步方法,在此处中断
                TcpClient client = listener.AcceptTcpClient();             
                RemoteClient wapper = new RemoteClient(client);
                wapper.BeginRead();
            }
        }
    }

    public class RemoteClient {
        private TcpClient client;
        private NetworkStream streamToClient;
        private const int BufferSize = 8192;
        private byte[] buffer;
        private ProtocolHandler handler;
       
        public RemoteClient(TcpClient client) {
            this.client = client;

            // 打印连接到的客户端信息
            Console.WriteLine("\nClient Connected!{0} <-- {1}",
                client.Client.LocalEndPoint, client.Client.RemoteEndPoint);

            // 获得流
            streamToClient = client.GetStream();
            buffer = new byte[BufferSize];

            handler = new ProtocolHandler();
        }

        // 开始进行读取
        public void BeginRead() {      
            AsyncCallback callBack = new AsyncCallback(OnReadComplete);
            streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
        }

        // 再读取完成时进行回调
        private void OnReadComplete(IAsyncResult ar) {
            int bytesRead = 0;
            try {
                lock (streamToClient) {
                    bytesRead = streamToClient.EndRead(ar);
                    Console.WriteLine("Reading data, {0} bytes ...", bytesRead);
                }
                if (bytesRead == 0) throw new Exception("读取到0字节");

                string msg = Encoding.Unicode.GetString(buffer, 0, bytesRead);
                Array.Clear(buffer,0,buffer.Length);        // 清空缓存,避免脏读

                // 获取protocol数组
                string[] protocolArray = handler.GetProtocol(msg);
                foreach (string pro in protocolArray) {
                    // 这里异步调用,不然这里可能会比较耗时
                    ParameterizedThreadStart start =
                        new ParameterizedThreadStart(handleProtocol);
                    start.BeginInvoke(pro, null, null);
                }

                // 再次调用BeginRead(),完成时调用自身,形成无限循环
                lock (streamToClient) {
                    AsyncCallback callBack = new AsyncCallback(OnReadComplete);
                    streamToClient.BeginRead(buffer, 0, BufferSize, callBack, null);
                }
            } catch(Exception ex) {
                if(streamToClient!=null)
                    streamToClient.Dispose();
                client.Close();
                Console.WriteLine(ex.Message);      // 捕获异常时退出程序
            }
        }

        // 处理protocol
        private void handleProtocol(object obj) {
            string pro = obj as string;
            ProtocolHelper helper = new ProtocolHelper(pro);
            FileProtocol protocol = helper.GetProtocol();

            if (protocol.Mode == FileRequestMode.Send) {
                // 客户端发送文件,对服务端来说则是接收文件
                receiveFile(protocol);
            } else if (protocol.Mode == FileRequestMode.Receive) {
                // 客户端接收文件,对服务端来说则是发送文件
                // sendFile(protocol);
            }
        }

        private void receiveFile(FileProtocol protocol) {
            // 获取远程客户端的位置
            IPEndPoint endpoint = client.Client.RemoteEndPoint as IPEndPoint;
            IPAddress ip = endpoint.Address;
           
            // 使用新端口号,获得远程用于接收文件的端口
            endpoint = new IPEndPoint(ip, protocol.Port);

            // 连接到远程客户端
            TcpClient localClient;
            try {
                localClient = new TcpClient();
                localClient.Connect(endpoint);
            } catch {
                Console.WriteLine("无法连接到客户端 --> {0}", endpoint);
                return;
            }

            // 获取发送文件的流
            NetworkStream streamToClient = localClient.GetStream();

            // 随机生成一个在当前目录下的文件名称
            string path =
                Environment.CurrentDirectory + "/" + generateFileName(protocol.FileName);

            byte[] fileBuffer = new byte[1024]; // 每次收1KB
            FileStream fs = new FileStream(path, FileMode.CreateNew, FileAccess.Write);

            // 从缓存buffer中读入到文件流中
            int bytesRead;
            int totalBytes = 0;
            do {
                bytesRead = streamToClient.Read(buffer, 0, BufferSize);            
                fs.Write(buffer, 0, bytesRead);
                totalBytes += bytesRead;
                Console.WriteLine("Receiving {0} bytes ...", totalBytes);
            } while (bytesRead > 0);

            Console.WriteLine("Total {0} bytes received, Done!", totalBytes);

            streamToClient.Dispose();
            fs.Dispose();
            localClient.Close();
        }

        // 随机获取一个图片名称
        private string generateFileName(string fileName) {
            DateTime now = DateTime.Now;
            return String.Format(
                "{0}_{1}_{2}_{3}", now.Minute, now.Second, now.Millisecond, fileName
            );
        }
    }

    这里应该没有什么新知识,需要注意的地方有这么几个:

    • 在OnReadComplete()回调方法中的foreach循环,我们使用委托异步调用了handleProtocol()方法,这是因为handleProtocol即将执行的是一个读取或接收文件的操作,也就是一个相对耗时的操作。
    • 在handleProtocol()方法中,我们深切体会了定义ProtocolHelper类和FileProtocol结构的好处。如果没有定义它们,这里将是不堪入目的处理XML以及类型转换的代码。
    • handleProtocol()方法中进行了一个条件判断,注意sendFile()方法我屏蔽掉了,这个还没有实现,但是我想你已经猜到它将是后面要实现的内容。
    • receiveFile()方法是实际接收客户端发来文件的方法,这里没有什么特别之处。需要注意的是文件存储的路径,它保存在了当前程序执行的目录下,文件的名称我使用generateFileName()生成了一个与时间有关的随机名称。

    3.2客户端的实现

    我们现在先不着急实现客户端S1、R1等用户菜单,首先完成发送文件这一功能,实际上,就是为上一节SendMessage()加一个姐妹方法SendFile()。

    class Client {
        static void Main(string[] args) {
            ConsoleKey key;

            ServerClient client = new ServerClient();
            string filePath = Environment.CurrentDirectory + "/" + "Client01.jpg";

            if(File.Exists(filePath))
                client.BeginSendFile(filePath);
           
            Console.WriteLine("\n\n输入\"Q\"键退出。");
            do {
                key = Console.ReadKey(true).Key;
            } while (key != ConsoleKey.Q);
        }
    }

    public class ServerClient {
        private const int BufferSize = 8192;
        private byte[] buffer;
        private TcpClient client;
        private NetworkStream streamToServer;

        public ServerClient() {
            try {
                client = new TcpClient();
                client.Connect("localhost", 8500);      // 与服务器连接
            } catch (Exception ex) {
                Console.WriteLine(ex.Message);
                return;
            }
            buffer = new byte[BufferSize];

            // 打印连接到的服务端信息
            Console.WriteLine("Server Connected!{0} --> {1}",
                client.Client.LocalEndPoint, client.Client.RemoteEndPoint);

            streamToServer = client.GetStream();
        }

        // 发送消息到服务端
        public void SendMessage(string msg) {

            byte[] temp = Encoding.Unicode.GetBytes(msg);   // 获得缓存
            try {
                lock (streamToServer) {
                    streamToServer.Write(temp, 0, temp.Length); // 发往服务器
                }
                Console.WriteLine("Sent: {0}", msg);
            } catch (Exception ex) {
                Console.WriteLine(ex.Message);
                return;
            }
        }

        // 发送文件 - 异步方法
        public void BeginSendFile(string filePath) {
            ParameterizedThreadStart start =
                new ParameterizedThreadStart(BeginSendFile);
            start.BeginInvoke(filePath, null, null);
        }

        private void BeginSendFile(object obj) {
            string filePath = obj as string;
            SendFile(filePath);
        }

        // 发送文件 -- 同步方法
        public void SendFile(string filePath) {

            IPAddress ip = IPAddress.Parse("127.0.0.1");
            TcpListener listener = new TcpListener(ip, 0);
            listener.Start();

            // 获取本地侦听的端口号
            IPEndPoint endPoint = listener.LocalEndpoint as IPEndPoint;
            int listeningPort = endPoint.Port;

            // 获取发送的协议字符串
            string fileName = Path.GetFileName(filePath);
            FileProtocol protocol =
                new FileProtocol(FileRequestMode.Send, listeningPort, fileName);
            string pro = protocol.ToString();

            SendMessage(pro);       // 发送协议到服务端

            // 中断,等待远程连接
            TcpClient localClient = listener.AcceptTcpClient();
            Console.WriteLine("Start sending file...");
            NetworkStream stream = localClient.GetStream();

            // 创建文件流
            FileStream fs = new FileStream(filePath, FileMode.Open, FileAccess.Read);          
            byte[] fileBuffer = new byte[1024];     // 每次传1KB
            int bytesRead;
            int totalBytes = 0;

            // 创建获取文件发送状态的类
            SendStatus status = new SendStatus(filePath);

            // 将文件流转写入网络流
            try {
                do {
                    Thread.Sleep(10);           // 为了更好的视觉效果,暂停10毫秒:-)
                    bytesRead = fs.Read(fileBuffer, 0, fileBuffer.Length);                 
                    stream.Write(fileBuffer, 0, bytesRead);
                    totalBytes += bytesRead;            // 发送了的字节数
                    status.PrintStatus(totalBytes); // 打印发送状态
                } while (bytesRead > 0);
                Console.WriteLine("Total {0} bytes sent, Done!", totalBytes);
            } catch {
                Console.WriteLine("Server has lost...");
            }
           
            stream.Dispose();
            fs.Dispose();
            localClient.Close();
            listener.Stop();
        }
    }

    接下来我们来看下这段代码,有这么两点需要注意一下:

    • 在Main()方法中可以看到,图片的位置为应用程序所在的目录,如果你跟我一样处于调试模式,那么就在解决方案的Bin目录下的Debug目录中放置三张图片Client01.jpg、Client02.jpg、Client03.jpg,用来发往服务端。
    • 我在客户端提供了两个SendFile()方法,和一个BeginSendFile()方法,分别用于同步和异步传输,其中私有的SendFile()方法只是一个辅助方法。实际上对于发送文件这样的操作我们几乎总是需要使用异步操作。
    • SendMessage()方法中给streamToServer加锁很重要,因为SendFile()方法是多线程访问的,而在SendFile()方法中又调用了SendMessage()方法。
    • 我另外编写了一个SendStatus类,它用来记录和打印发送完成的状态,已经发送了多少字节,完成度是百分之多少,等等。本来这个类的内容我是直接写入在Client类中的,后来我觉得它执行的工作已经不属于Client本身所应该执行的领域之内了,我记得这样一句话:当你觉得类中的方法与类的名称不符的时候,那么就应该考虑重新创建一个类。我觉得用在这里非常恰当。

    下面是SendStatus的内容:

    // 即时计算发送文件的状态
    public class SendStatus {
        private FileInfo info;
        private long fileBytes;

        public SendStatus(string filePath) {
            info = new FileInfo(filePath);
            fileBytes = info.Length;
        }

        public void PrintStatus(int sent) {
            string percent = GetPercent(sent);
            Console.WriteLine("Sending {0} bytes, {1}% ...", sent, percent);
        }

        // 获得文件发送的百分比
        public string GetPercent(int sent){    

            decimal allBytes = Convert.ToDecimal(fileBytes);
            decimal currentSent = Convert.ToDecimal(sent);

            decimal percent = (currentSent / allBytes) * 100;
            percent = Math.Round(percent, 1);   //保留一位小数
           
            if (percent.ToString() == "100.0")
                return "100";
            else
                return percent.ToString();
        }
    }

    3.3程序测试

    接下里我们运行一下程序,来检查一下输出,首先看下服务端:

    接着是客户端,我们能够看到发送的字节数和进度,可以想到如果是图形界面,那么我们可以通过扩展SendStatus类来创建一个进度条:

    最后我们看下服务端的Bin\Debug目录,应该可以看到接收到的图片:

    本来我想这篇文章就可以完成发送和接收,不过现在看来没法实现了,因为如果继续下去这篇文章就太长了,我正尝试着尽量将文章控制在15页以内。那么我们将在下篇文章中再完成接收文件这一部分。

  • 相关阅读:
    POJ 3261 Milk Patterns (求可重叠的k次最长重复子串)
    UVaLive 5031 Graph and Queries (Treap)
    Uva 11996 Jewel Magic (Splay)
    HYSBZ
    POJ 3580 SuperMemo (Splay 区间更新、翻转、循环右移,插入,删除,查询)
    HDU 1890 Robotic Sort (Splay 区间翻转)
    【转】ACM中java的使用
    HDU 4267 A Simple Problem with Integers (树状数组)
    POJ 1195 Mobile phones (二维树状数组)
    HDU 4417 Super Mario (树状数组/线段树)
  • 原文地址:https://www.cnblogs.com/qisheng/p/2352779.html
Copyright © 2020-2023  润新知