C#⽹络编程(Socket监听和连接)
C#⽹络编程(基本概念和操作) - Part.1
引⾔
C#⽹络编程系列⽂章计划简单地讲述⽹络编程⽅⾯的基础知识,由于本⼈在这⽅⾯功⼒有限,所以只能提供⼀些初步的⼊门知识,希望能对刚开始学习的朋友提供⼀些帮助。如果想要更加深⼊的内容,可以参考相关书籍。
本⽂是该系列第⼀篇,主要讲述了基于套接字(Socket)进⾏⽹络编程的基本概念,其中包括TCP协议、套接字、聊天程序的三种开发模式,以及两个基本操作:侦听端⼝、连接远程服务端;第⼆篇讲述了⼀个简单的范例:从客户端传输字符串到服务端,服务端接收并打印字符串,将字符串改为⼤写,然后再将字符串回发到客户端,客户端最后打印传回的字符串;第三篇是第⼆篇的⼀个强化,讲述了第⼆篇中没有解决的⼀个问题,并使⽤了异步传输的⽅式来完成和第⼆篇同样的功能;第四篇则演⽰了如何在客户端与服务端之间收发⽂件;第五篇实现了⼀个能够并进⾏⽂件传输的聊天程序,实际上是对前⾯知识的⼀个综合应⽤。
与本⽂相关的还有⼀篇⽂章是:,但这个聊天程序不及本系列中的聊天程序功能强⼤,实现⽅式也不相同。
⽹络编程基本概念
1.⾯向连接的传输协议:TCP
对于TCP协议我不想说太多东西,这属于⼤学课程,⼜涉及计算机科学,⽽我不是“学院派”,对于这部分内容,我觉得作为开发⼈员,只需要掌握与程序相关的概念就可以了,不需要做太艰深的研究。
我们⾸先知道TCP是⾯向连接的,它的意思是说两个远程主机(或者叫进程,因为实际上远程通信是进程之间的通信,⽽进程则是运⾏中的程序),必须⾸先进⾏⼀个握⼿过程,确认连接成功,之后才能传输实际的数据。⽐如说进程A想将字符串“It's a fine day today”发给进程B,它⾸先要建⽴连接。在这⼀过程中,它⾸先需要知道进程B的位置(主机地址和端⼝号)。随后发送⼀个不包含实际数据的请求报⽂,我们可以将这个报⽂称之为“hello”。如果进程B接收到了这个“hello”,就向进程A回复⼀个“hello”,进程A随后才发送实际的数据“It's a fine day today”。
关于TCP第⼆个需要了解的,就是它是全双⼯的。意思是说如果两个主机上的进程(⽐如进程A、进程B),⼀旦建⽴好连接,那么数据就既可以由A流向B,也可以由B流向A。除此以外,它还是点对点的,意思是说⼀个TCP连接总是两者之间的,在发送中,通过⼀个连接将数据发给多个接收⽅是不可能的。TCP还有⼀个特性,就是称为可靠的数据传输,意思是连接建⽴后,数据的发送⼀定能够到达,并且是有序的,就是说发的时候你发了ABC,那么收的⼀⽅收到的也⼀定是ABC,⽽不会是BCA
或者别的什么。
编程中与TCP相关的最重要的⼀个概念就是套接字。我们应该知道⽹络七层协议,如果我们将上⾯的应⽤程、表⽰层、会话层笼统地算作⼀层(有的教材便是如此划分的),那么我们编写的⽹络应⽤程序就位于应⽤层,⽽⼤家知道TCP是属于传输层的协议,那么我们在应⽤层如何使⽤传输层的服务呢(消息发送或者⽂件上传下载)?⼤家知道在应⽤程序中我们⽤接⼝来分离实现,在应⽤层和传输层之间,则是使⽤套接字来进⾏分离。它就像是传输层为应⽤层开的⼀个⼩⼝,应⽤程序通过这个⼩⼝向远程发送数据,或者接收远程发来的数据;⽽这个⼩⼝以内,也就是数据进⼊这个⼝之后,或者数据从这个⼝出来之前,我们是不知道也不需要知道的,我们也不会关⼼它如何传输,这属于⽹络其它层次的⼯作。
举个例⼦,如果你想写封邮件发给远⽅的朋友,那么你如何写信、将信打包,属于应⽤层,信怎么写,怎么打包完全由我们做主;⽽当我们将信投⼊邮筒时,邮筒的那个⼝就是套接字,在进⼊套接字之后,就是传输层、⽹络层等(邮局、公路交管或者航线等)其它层次的⼯作了。我们从来不会去关⼼信是如何从西安发往北京的,我们只知道写好了投⼊邮筒就OK了。可以⽤下⾯这两幅图来表⽰它:
注意在上⾯图中,两个主机是对等的,但是按照约定,我们将发起请求的⼀⽅称为客户端,将另⼀端称为服务端。可以看出两个程序之间的对话是通过套接字这个出⼊⼝来完成的,实际上套接字包含的
最重要的也就是两个信息:连接⾄远程的本地的端⼝信息(本机地址和端⼝号),连接到的远程的端⼝信息(远程地址和端⼝号)。注意上⾯词语的微妙变化,⼀个是本地地址,⼀个是远程地址。
这⾥⼜出现了了⼀个名词端⼝。⼀般来说我们的计算机上运⾏着⾮常多的应⽤程序,它们可能都需要同远程主机打交道,所以远程主机就需要有⼀个ID来标识它想与本地机器上的哪个应⽤程序打交道,这⾥的ID就是端⼝。将端⼝分配给⼀个应⽤程序,那么来⾃这个端⼝的数据则总是针对这个应⽤程序的。有这样⼀个很好的例⼦:可以将主机地址想象为电话号码,⽽将端⼝号想象为分机号。
在.NET中,尽管我们可以直接对套接字编程,但是.NET提供了两个类将对套接字的编程进⾏了⼀个封装,使我们的使⽤能够更加⽅便,这两个类是TcpClient和TcpListener,它与套接字的关系如下:
从上⾯图中可以看出TcpClient和TcpListener对套接字进⾏了封装。从中也可以看出,TcpListener位于接收流的位置,TcpClient位于输出流的位置(实际上TcpListener在收到⼀个请求后,就创建了TcpClient,⽽它本⾝则持续处于侦听状态,收发数据都可以由TcpClient完成。这个图有点不够准确,⽽我暂时没有想到更好的画法,后⾯看到代码时会更加清楚⼀些)。
我们考虑这样⼀种情况:两台主机,主机A和主机B,起初它们谁也不知道谁在哪⼉,当它们想要进⾏对话时,总是需要有⼀⽅发起连接,⽽另⼀⽅则需要对本机的某⼀端⼝进⾏侦听。⽽在侦听⽅收到连接请求、并建⽴起连接以后,它们之间进⾏收发数据时,发起连接的⼀⽅并不需要再进⾏侦听。因为
连接是全双⼯的,它可以使⽤现有的连接进⾏收发数据。⽽我们前⾯已经做了定义:将发起连接的⼀⽅称为客户端,另⼀段称为服务端,则现在可以得出:总是服务端在使⽤TcpListener类,因为它需要建⽴起⼀个初始的连接。
2.⽹络聊天程序的三种模式
实现⼀个⽹络聊天程序本应是最后⼀篇⽂章的内容,也是本系列最后的⼀个程序,来作为⼀个终结。但是我想后⾯更多的是编码,讲述的内容应该不会太多,所以还是把讲述的东西都放到这⾥吧。
当采⽤这种模式时,即是所谓的完全点对点模式,此时每台计算机本⾝也是服务器,因为它需要进⾏端⼝的侦听。实现这个模式的难点是:各个主机(或终端)之间如何知道其它主机的存在?此时通常的做法是当某⼀主机上线时,使⽤UDP协议进⾏⼀个⼴播(Broadcast),通过这种⽅式来“告知”其它主机⾃⼰已经在线并说明位置,收到⼴播的主机发回⼀个应答,此时主机便知道其他主机的存在。这种⽅式我个⼈并不喜欢,但在这篇⽂章中,我使⽤了这种模式,可惜的是我没有实现⼴播,所以还很不完善。
第⼆种⽅式较好的解决了上⾯的问题,它引⼊了服务器,由这个服务器来专门进⾏⼴播。服务器持续保持对端⼝的侦听状态,每当有主机上线时,⾸先连接⾄服务器,服务器收到连接后,将该主机的位置(地址和端⼝号)发往其他在线主机(绿⾊箭头标识)。这样其他主机便知道该主机已上线,并知
道其所在位置,从⽽可以进⾏连接和对话。在服务器进⾏了⼴播之后,因为各个主机已经知道了其他主机的位置,因此主机之间的对话就不再通过服务器(⿊⾊箭头表⽰),⽽是直接进⾏连接。因此,使⽤这种模式时,各个主机依然需要保持对端⼝的侦听。在某台主机离线时,与登录时的模式类似,服务器会收到通知,然后转告给其他的主机。
第三种模式是我觉得最简单也最实⽤的⼀种,主机的登录与离线与第⼆种模式相同。注意到每台主机在上线时⾸先就与服务器建⽴了连接,那么从主机A发往主机B发送消息,就可以通过这样⼀条路径,主机A --> 服务器 --> 主机B,通过这种⽅式,各个主机不需要在对端⼝进⾏侦听,⽽只需要服务器进⾏侦听就可以了,⼤⼤地简化了开发。
⽽对于⼀些较⼤的⽂件,⽐如说图⽚或者⽂件,如果想由主机A发往主机B,如果通过服务器进⾏传输效率会⽐较低,此时可以临时搭建⼀个主机A⾄主机B之间的连接,⽤于传输⼤⽂件。当⽂件传输结束之后再关闭连接(桔红⾊箭头标识)。
除此以外,由于消息都经过服务器,所以服务器还可以缓存主机间的对话,即是说当主机A发往主机B时,如果主机B已经离线,则服务器可以对消息进⾏缓存,当主机B下次连接到服务器时,服务器⾃动将缓存的消息发给主机B。
本系列⽂章最后采⽤的即是此种模式,不过没有实现过多复杂的功能。接下来我们的理论知识告⼀段
落,开始下⼀阶段――漫长的编码。基本操作
1.服务端对端⼝进⾏侦听
接下来我们开始编写⼀些实际的代码,第⼀步就是开启对本地机器上某⼀端⼝的侦听。⾸先创建⼀个控制台应⽤程序,将项⽬名称命名为ServerConsole,它代表我们的服务端。如果想要与外界进⾏通信,第⼀件要做的事情就是开启对端⼝的侦听,这就像为计算机打开了⼀
个“门”,所有向这个“门”发送的请求(“敲门”)都会被系统接收到。在C#中可以通过下⾯⼏个步骤完成,⾸先使⽤本机Ip地址和端⼝号创建⼀个System.Net.Sockets.TcpListener类型的实例,然后在该实例上调⽤Start()⽅法,从⽽开启对指定端⼝的侦听。
using System.Net; // 引⼊这两个命名空间,以下同
using System.Net.Sockets;
using ... // 略
class Server {
static void Main(string[] args) {
Console.WriteLine("Server is running ... ");
IPAddress ip = new IPAddress(new byte[] { 127, 0, 0, 1 });
TcpListener listener = new TcpListener(ip, 8500);
listener.Start(); // 开始侦听
Console.WriteLine("Start Listening ...");
Console.WriteLine("\n\n输⼊\"Q\"键退出。");
ConsoleKey key;
do {
key = Console.ReadKey(true).Key;
} while (key != ConsoleKey.Q);
}
}
// 获得IPAddress对象的另外⼏种常⽤⽅法:
IPAddress ip = IPAddress.Parse("127.0.0.1");
IPAddress ip = Dns.GetHostEntry("localhost").AddressList[0];
上⾯的代码中,我们开启了对8500端⼝的侦听。在运⾏了上⾯的程序之后,然后打开“命令提⽰符”,输⼊“netstat-a”,可以看到计算机器中所有打开的端⼝的状态。可以从中到8500端⼝,看到它的状态是LISTENING,这说明它已经开始了侦听:
TCP jimmy:1030 0.0.0.0:0 LISTENING
TCP jimmy:3603 0.0.0.0:0 LISTENING
TCP jimmy:8500 0.0.0.0:0 LISTENING
TCP jimmy:netbios-ssn 0.0.0.0:0 LISTENING
在打开了对端⼝的侦听以后,服务端必须通过某种⽅式进⾏阻塞(⽐如Console.ReadKey()),使得
程序不能够因为运⾏结束⽽退出。否则就⽆法使⽤“netstat -a”看到端⼝的连接状态,因为程序已经退出,连接会⾃然中断,再运⾏“netstat -a”当然就不会显⽰端⼝了。所以程序最后按“Q”退出那段代码是必要的,下⾯的每段程序都会含有这个代码段,但为了节省空间,我都省略掉了。
2.客户端与服务端连接
2.1单⼀客户端与服务端连接
当服务器开始对端⼝侦听之后,便可以创建客户端与它建⽴连接。这⼀步是通过在客户端创建⼀个TcpClient的类型实例完成。每创建⼀个新的TcpClient便相当于创建了⼀个新的套接字Socket去与服务端通信,.Net会⾃动为这个套接字分配⼀个端⼝号,上⾯说过,TcpClient类不过是对Socket进⾏了⼀个包装。创建TcpClient类型实例时,可以在构造函数中指定远程服务器的地址和端⼝号。这样在创建的同时,就会向远程服务端发送⼀个连接请求(“握⼿”),⼀旦成功,则两者间的连接就建⽴起来了。也可以使⽤重载的⽆参数构造函数创建对象,然后再调⽤Connect()⽅法,在Connect()⽅法中传⼊远程服务器地址和端⼝号,来与服务器建⽴连接。
这⾥需要注意的是,不管是使⽤有参数的构造函数与服务器连接,或者是通过Connect()⽅法与服务器建⽴连接,都是同步⽅法(或者说是阻塞的,英⽂叫block)。它的意思是说,客户端在与服务端连接成功、从⽽⽅法返回,或者是服务端不存、从⽽抛出异常之前,是⽆法继续进⾏后继操作的。这⾥还
有⼀个名为BeginConnect()的⽅法,⽤于实施异步的连接,这样程序不会被阻塞,可以⽴即执⾏后⾯的操作,这是因为可能由于⽹络拥塞等问题,连接需要较长时间才能完成。⽹络编程中有⾮常多的异步操作,凡事都是由简⼊难,关于异步操作,我们后⾯再讨论,现在只看同步操作。
创建⼀个新的控制台应⽤程序项⽬,命名为ClientConsole,它是我们的客户端,然后添加下⾯的代码,创建与服务器的连接:
class Client {
static void Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client = new TcpClient();
try {
client.Connect("localhost", 8500); // 与服务器连接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印连接到的服务端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
// 按Q退出
}
}
上⾯带代码中,我们通过调⽤Connect()⽅法来与服务端连接。随后,我们打印了这个连接消息:本机的Ip地址和端⼝号,以及连接到的远程Ip地址和端⼝号。TcpClient的Client属性返回了⼀个Socket对象,它的LocalEndPoint和RemoteEndPoint属性分别包含了本地和远程的地址信息。先运⾏服务端,
再运⾏这段代码。可以看到两边的输出情况如下:
// 服务端:
Server is running ...
Start Listening ...
// 客户端:
Client Running ...
Server Connected!127.0.0.1:4761 --> 127.0.0.1:8500
我们看到客户端使⽤的端⼝号为4761,上⾯已经说过,这个端⼝号是由.NET随机选取的,并不需要我们来设置,并且每次运⾏时,这个端⼝号都不同。再次打开“命令提⽰符”,输⼊“netstat -a”,可以看到下⾯的输出:
TCP jimmy:8500 0.0.0.0:0 LISTENING
TCP jimmy:8500 localhost:4761 ESTABLISHED
TCP jimmy:4761 localhost:8500 ESTABLISHED
从这⾥我们可以得出⼏个重要信息:1、端⼝8500和端⼝4761建⽴了连接,这个4761端⼝便是客户端⽤来与服务端进⾏通信的端⼝;2、8500端⼝在与客户端建⽴起⼀个连接后,仍然继续保持在监听状态。这也就是说⼀个端⼝可以与多个远程端⼝建⽴通信,这是显然的,⼤家众所周之的HTTP使⽤的默认端⼝为80,但是⼀个Web服务器要通过这个端⼝与多少个浏览器通信啊。
2.2多个客户端与服务端连接
那么既然⼀个服务器端⼝可以应对多个客户端连接,那么接下来我们就看⼀下,如何让多个客户端与服务端连接。如同我们上⾯所说的,⼀
个TcpClient就是⼀个Socket,所以我们只要创建多个TcpClient,然后再调⽤Connect()⽅法就可以了:
class Client {
static void Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client;
for (int i = 0; i <= 2; i++) {
try {
client = new TcpClient();
client.Connect("localhost", 8500); // 与服务器连接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
writeline方法属于类}
// 打印连接到的服务端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
}
// 按Q退出
}
}
上⾯代码最重要的就是client = new TcpClient()这句,如果你将这个声明放到循环外⾯,再循环的第⼆趟就会发⽣异常,原因很显然:⼀个TcpClient对象对应⼀个Socket,⼀个Socket对应着⼀个端⼝,如果不使⽤new操作符重新创建对象,那么就相当于使⽤⼀个已经与服务端建⽴了连接的端⼝再次与远程建⽴连接。
此时,如果在“命令提⽰符”运⾏“netstat -a”,则会看到类似下⾯的的输出:
TCP jimmy:8500 0.0.0.0:0 LISTENING
TCP jimmy:8500 localhost:10282 ESTABLISHED
TCP jimmy:8500 localhost:10283 ESTABLISHED
TCP jimmy:8500 localhost:10284 ESTABLISHED
TCP jimmy:10282 localhost:8500 ESTABLISHED
TCP jimmy:10283 localhost:8500 ESTABLISHED
TCP jimmy:10284 localhost:8500 ESTABLISHED
可以看到创建了三个连接对,并且8500端⼝持续保持侦听状态,从这⾥以及上⾯我们可以推断出TcpListener的Start()⽅法是⼀个异步⽅法。
3.服务端获取客户端连接
3.1获取单⼀客户端连接
上⾯服务端、客户端的代码已经建⽴起了连接,这通过使⽤“netstat -a”命令,从端⼝的状态可以看出来,但这是操作系统告诉我们的。那么我们现在需要知道的就是:服务端的程序如何知道已经与⼀个客户端建⽴起了连接?
服务器端开始侦听以后,可以在TcpListener实例上调⽤AcceptTcpClient()来获取与⼀个客户端的连接,
它返回⼀个TcpClient类型实例。此时它所包装的是由服务端去往客户端的Socket,⽽我们在客户端创建的TcpClient则是由客户端去往服务端的。这个⽅法是⼀个同步⽅法(或者叫阻断⽅法,block method),意思就是说,当程序调⽤它以后,它会⼀直等待某个客户端连接,然后才会返回,否则就会⼀直等下去。这样的话,在调⽤它以后,除⾮得到⼀个客户端连接,不然不会执⾏接下来的代码。⼀个很好的类⽐就是Console.ReadLine()⽅法,它读取输⼊在控制台中的⼀⾏字符串,如果有输⼊,就继续执⾏下⾯代码;如果没有输⼊,就会⼀直等待下去。
class Server {
static void Main(string[] args) {
Console.WriteLine("Server is running ... ");
IPAddress ip = new IPAddress(new byte[] { 127, 0, 0, 1 });
TcpListener listener = new TcpListener(ip, 8500);
listener.Start(); // 开始侦听
Console.WriteLine("Start Listening ...");
// 获取⼀个连接,中断⽅法
TcpClient remoteClient = listener.AcceptTcpClient();
// 打印连接到的客户端信息
Console.WriteLine("Client Connected!{0} <-- {1}",
remoteClient.Client.LocalEndPoint, remoteClient.Client.RemoteEndPoint);
// 按Q退出
}
}
运⾏这段代码,会发现服务端运⾏到listener.AcceptTcpClient()时便停⽌了,并不会执⾏下⾯的Console.WriteLine()⽅法。为了让它继续执⾏下去,必须有⼀个客户端连接到它,所以我们现在运⾏客户端,与它进⾏连接。简单起见,我们只在客户端开启⼀个端⼝与之连接:
class Client {
static void Main(string[] args) {
Console.WriteLine("Client Running ...");
TcpClient client = new TcpClient();
try {
client.Connect("localhost", 8500); // 与服务器连接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印连接到的服务端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
// 按Q退出
}
}
此时,服务端、客户端的输出分别为:
// 服务端
Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5188
// 客户端
Client Running ...
Server Connected!127.0.0.1:5188 --> 127.0.0.1:8500
3.2获取多个客户端连接
现在我们再接着考虑,如果有多个客户端发动对服务器端的连接会怎么样,为了避免你将浏览器向上滚动,来查看上⾯的代码,我将它拷贝了下来,我们先看下客户端的关键代码:
TcpClient client;
for (int i = 0; i <=2; i++) {
try {
client = new TcpClient();
client.Connect("localhost", 8500); // 与服务器连接
} catch (Exception ex) {
Console.WriteLine(ex.Message);
return;
}
// 打印连接到的服务端信息
Console.WriteLine("Server Connected!{0} --> {1}",
client.Client.LocalEndPoint, client.Client.RemoteEndPoint);
}
如果服务端代码不变,我们先运⾏服务端,再运⾏客户端,那么接下来会看到这样的输出:
// 服务端
Server is running ...
Start Listening ...
Client Connected!127.0.0.1:8500 <-- 127.0.0.1:5226
// 客户端
Client Running ...
版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。
发表评论