Java系列学习笔记 --- 网络编程(6)Socket

目录

一、TCP协议基础

二、Socket

       2.1 Socket的作用

       2.2 Socket概念

       2.3 Socket的基本操作

              ① 构造和连接Socket

              ② 设置连接超时

              ③ 读取数据

              ④ 加入多线程

三、半关闭Socket

四、Socket编程的额外知识


       TCP/IP通信协议是一种可靠的网络协议,它在通信的两端各建立一个Socket,从而在通信的两端形成虚拟的网络链路。一旦建立了虚拟的网络链路,两端的程序就可以通过虚拟链路进行通信。

       Java对基于TCP协议的网络通信提供了良好的封装,Java使用Socket对象来代表两端的通信端口,并通过Socket产生IO流来进行网络通信。

一、TCP协议基础

       IP协议只负责将消息从一个主机传送到另一个主机,保证计算机之间可以发送和接收数据,但无法解决数据分组在传送过程中可能出现的问题。因此,计算机之间还使用了TCP协议来保证可靠且无差错的通信服务。

       TCP协议被称作一种端对端协议,它是面向连接的运输层协议。它负责收集数据分包,使用前必须建立TCP连接,之后按照次数据序放好传送,接收端收到后再按照次序正确还原,保证数据包在传送中准确无误,之后必须释放TCP连接。

       TCP协议使用重发机制——当客户端发送一个数据分包给服务器之后,客户端需要收到服务器反馈的确认信息,如果客户端没有收到服务器的确认信息,则会再次重发刚才的数据分包。

       TCP协议为应用程序提供了可靠的通信交付服务。用过TCP连接传送的数据,无差错、不丢失、不重复、并且按序到达。例如Internet出现网络拥塞的情况下也能保证数据的可靠,使它能够自动适应网上的各种变化。

       综上所述,虽然TCP和IP这两个协议的功能尽不相同,也可以单独使用,但它们是作为一个互补协议来设计,保证网络的正常运行。凡是连接到Internet的计算机都必须同时安装和使用这两个协议,因此统称TCP/IP协议

二、Socket

2.1 Socket的作用

       Socket是TCP/IP协议封装好的编程接口,网络上的两个程序基本上都是通过Socket进行通信的。Socket又称为套接字,用于描述IP地址端口是一个通信链的句柄,应用程序之间通常是使用Socket编程接口进行网络通信的。

       Java的Socket就是Socket编程接口的封装实现,它封装了底层操作系统的复杂实现,简化了使用方式,使我们能够更容易的实现网络通信功能。

       综上所述,Socket是基于TCP/IP协议的编程接口,用来建立网络通信的例如,浏览器每个标签页的进程与Web服务器通信QQ每个聊天窗口之间的通信都离不开Socket。

2.2 Socket概念

       Socket是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。在设计模式中,Socket将复杂的TCP/IP协议族隐藏在Socket接口后面,通过该接口组织数据,已符合指定的协议。

       Java应用程序通常使用Socket类和ServerSocket类向网络发出请求或者应答网络请求。其中,Socket类用于建立网络连接,ServerSocket用于服务器端。TCP连接成功时,应用程序两端都会产生一个Socket实例,操作这个实例,完成所需的会话。

       Socket可以完成客户端和服务器通用的4个基本操作:连接远程机器、发送数据、接收数据、关闭连接。服务器还可以额外完成3个基本操作:绑定端口、监听入站数据、在绑定端口上接受远程主机的连接。例如下图所示

       服务器端先初始化Socket,然后绑定(bind)IP地址或端口,并对端口进行监听(listen),调用accept进行阻塞,等待客户端连接。此时,有个客户端初始化一个Socket,连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把响应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。

       Socket通信模型如下图所示

 

 

2.3 Socket的基本操作

① 构造和连接Socket

       Socket类是Java完成客户端TCP操作的基础类。其他建立TCP网络连接的面向客户端类(如URL、URLConnection、Applet)最终都会调用这个类的方法。这个类本身使用原生代码与主机操作系统的本地TCP栈进行通信。ServerSocket包含一个监听来自客户端连接请求的方法,它能够不断地接受来自客户端的所有请求,所以Java程序通常会循环不断地调用ServerSocket的accept()方法,例如下面代码所示

// 创建一个ServerSocket,用于监听客户端Socket的链接请求
ServerSocket ss = new ServerSocket(80);
// 采用循环不断地接收来自客户端的请求
while(true){
    // 每当接收客户端Socket请求时,服务器端也对于产生一个Socket
    Socket s = ss.accept();
    // 下面代码就可以使用Socket进行通信了
    ...
}

       客户端程序在使用套接字前,必须调用Socket()函数创建套接字对象。每个Socket构造函数均要指定要连接的主机和端口,主机的指定类型为InetAddress或String,如下所示。

Socket (String host, int port):创建流套接字并将其连接到指定主机上的指定端口号。
Socket (InetAddress address, int port):创建流套接字并将其连接到指定IP地址处的指定端口号。

       以上两种常见的构造函数会在构造函数返回之前,会与远程主机建立一个活动的网络连接,然后返回一个Socket对象。如果由于某种原因未能打开连接,构造函数会抛出一个IOException或UnknownHostException异常。

try{
    Socket socket = new Socket("www.baidu.com",80);
    //发送和接收数据...
}catch(UnknownHostException ex){
    System.err.println(ex);
}catch(IOException ex){
    System.err.println(ex);
}

       上面示例中,host参数使用String表示一个主机名。当程序执行这行代码时,将会连接到指定服务器,让服务器端的ServerSocket的accept()方法向下执行,于是服务器端和客户端就产生一对互相连接的Socket,如下图所示。

       如果域名服务器无法解析主机名或域名服务器没有运行,则会抛出UnknownHostException异常。如果出于某些原因未能打开Socket,则会抛出IOException异常。连接失败有很多原因,例如:如果没有登录酒店网站并交付一定金额,无法使用酒店的WiFi服务、连接主机上的端口没有开放不接受连接、路由阻塞等。

       下面我们通过cmd指令控制台输入netstat -ano指令来查看本地主机所有端口的使用情况,并查看端口1~1023的信息如下图所示

 

       接下来我们开启Web服务器(Tomcat),之后我们通过Java程序访问本地服务器中1~1013的所有端口,例如下面的代码所示:

public static void main(String[] args){
		String host = args.length > 0 ? args[0] : "localhost";
		for(int i=1;i<1024;i++){
			try{
				Socket client = new Socket(host, i);
				System.out.println("连接到主机:" + host + " ,端口号:" + i);
				client.close();
			}catch(UnknownHostException ex){
				break;
			}catch(IOException ex){}
		}
}

       最终的输出结果如下所示:

连接到主机:localhost ,端口号:80
连接到主机:localhost ,端口号:135
连接到主机:localhost ,端口号:445

       从这个结果可以看出,程序运行时如果服务器(例如Tomcat服务器)没有开启,程序会报IOException异常;如果服务器已经开启,程序会遍历该服务器主机上1~1023端口的所有Socket,已经开启的端口会建立Socket连接,否则程序也会报IOException异常。异常如下所示

java.net.ConnectException: Connection refused: connect

② 设置连接超时

       在实际应用中,如果不想程序执行网络连接、连接服务器数据的进程一直阻塞,而是希望当网络连接、读取操作超过指定时间之后,系统自动认为操作失败,这个时间就是超时时长。

       Socket对象提供了setSoTimeout(int timeout)方法来设置超时时长,如下面代码所示。

Socket s = new Socket("localhost",80);
s.setSoTimeout(10000);	//设置10秒后认为超时

       为Socket对象指定超时时长之后,如果在使用Socket进行读、写操作完成之前超过了指定时长,那么程序会抛出SocketTimeoutException异常。

       如果程序需要为Socket连接服务器时指定超时时长,程序可以通过创建一个无连接的Socket对象,调用该对象的connect()方法来连接远程服务器并设置超时时长参数。如下面代码所示。

Socket s = new Socket();
//通过connect()方法连接到远程服务器,如果10秒之后还没连接上,则认为连接超时
s.connect(new InetAddress("localhost",80),10000);

       这两者不同点在于,setSoTimeout()方法设置连接成功之后的超时时间,防止服务器将线程挂起connect()方法设置连接之前的连接超时时间。

 读取数据

       当客户端、服务器端产生了对应的Socket之后,程序无需再区分服务器端、客户端,而是通过各自的Socket进行通信。我们可以通过下面两个方法来获取输入流和输出流。

InputStream getInputStream():返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。
OutputStream getOutputStream():返回该Socket对象对应的输出流,让程序通过该输出流向Socket中输出数据。

       从这两个方法返回的InputStream和OutputStream,我们可以明白Java在设计IO体系上的苦心 —— 不管底层IO流时怎样的节点:文件流也好、网络Socket产生的流也好,程序都可以将其包装成处理流。

       下面通过演示程序使用Socket获取输入流读取数据和输出流输出数据

       1).服务器端程序 —— 建立ServerSocket监听,并获取输出流输出数据

public class KeTangServer {
    public static final int PORT = 80;	//服务器端口号
    public static final int TIME_OUT = 10*1000;	//连接超时时间
	public static void main(String[] args) throws IOException {
		// 创建ServerSocket对象,监听指定端口的Socket的链接请求,并设置超时时间
		ServerSocket serverSocket = new ServerSocket(PORT);
		serverSocket.setSoTimeout(TIME_OUT);
		// 采用循环不断地接收来自客户端的请求
		while(true){
		   // 每当接收客户端Socket请求时,服务器端产生一个对应的Socket对象
		   Socket socket = serverSocket.accept();
	       // 【接收客户端数据】 —— 打开输入流(入)
            DataInputStream input = new DataInputStream(socket.getInputStream());
            String clientInputStr = input.readUTF();
            System.out.println("【服务器】接收消息:" + clientInputStr); 
            // 【响应客户端】 —— 打开输出流(出)
            DataOutputStream out = new DataOutputStream(socket.getOutputStream());    
            System.out.print("【服务器】响应消息: ");    
            String ServerOutputStr = new BufferedReader(new InputStreamReader(System.in)).readLine();    
            out.writeUTF(ServerOutputStr);    
            //关闭流
            out.close();
            input.close();    
		}
	}
}

       2).客户端程序 —— 使用Socket建立与指定IP地址、指定端口的链接,并获取输入流读取数据

public class Client{
    public static final String IP_ADDR = "localhost";	//服务器地址   
    public static final int PORT = 80;	//服务器端口号    
    public static void main(String[] args) throws IOException {
        // 创建Socket连接对象
        Socket socket = new Socket(IP_ADDR,PORT);
        //【向服务器发送信息】 —— 打开输出流(出)
        DataOutputStream out = new DataOutputStream(socket.getOutputStream());
        System.out.print("【客户端】发送消息: ");    
        String clientStr = new BufferedReader(new InputStreamReader(System.in)).readLine();    
        out.writeUTF(clientStr);
        //【接收服务器的响应信息】 —— 打开输入流(入)
        DataInputStream input = new DataInputStream(socket.getInputStream());    
        String ServerText = input.readUTF();     
        System.out.println("【客户端】获取响应消息: " + ServerText);   
        //关闭流
        out.close();
        input.close();   
    }
}

       输出结果如下图所示

       上面的范例中,运行Server类之后,服务进程会一直处于等待状态(监听状态),因为服务器使用了死循环来接收客户端的请求;再运行Client类,控制台输入要发送的消息之后,服务器接收到消息客户端所发送的消息,然后客户端再接收服务器的响应消息。

       注意:ServerSocket和Socket建立网络连接之后,通过Socket获取输出流和输入流进行通信,网络通信过程与普通IO并没有太大的区别。另Socket输出流写入数据方法是 writeUTF 时,输入流读取数据要用 readUTF否则会抛EOFException异常。

 加入多线程

       在使用传统BufferedReader的readLine()方法读取数据时,在成功返回之前,线程都会被阻塞,程序无法继续执行。为此,Server和Client程序应该为每个Socket单独启动一个线程,每个线程负责与一个客户端进行通信。

       例如C/S聊天室应用,服务器端应该包含多个线程,负责读取对应Socket对象的输入流数据(从客户端发送过来的数据),因此需要在服务器端使用List来保存所有的Socket。代码如下所示。

       1).服务器范例代码

public static final int PORT = 80; // 服务器端口号
public static final int TIME_OUT = 15 * 1000; // 连接超时时间
public static void main(String[] args) {
	System.err.println("启动服务器...");
	KeTangServer server = new KeTangServer();
	server.start();
}
public void start() {
	try {
		// 创建ServerSocket对象,监听指定端口的Socket的链接请求,并设置超时时间
		ServerSocket serverSocket = new ServerSocket(PORT);
		serverSocket.setSoTimeout(TIME_OUT);
		while (true) {
			// 一旦有堵塞, 则表示服务器与客户端获得了连接
			Socket client = serverSocket.accept();
			// 处理这次连接
			new HandlerThread(client);
		}
	} catch (Exception e) {
		System.err.println("服务器异常: " + e.getMessage());
	}
}
private class HandlerThread implements Runnable {
	private Socket socket;	//Socket对象
	public HandlerThread(Socket client) {
		socket = client;
		new Thread(this).start();
	}
	public void run() {
		try {
			// 【接收客户端数据】 —— 打开输入流(入)
			DataInputStream input = new DataInputStream(socket.getInputStream());
			String clientInputStr = input.readUTF();
			System.out.println("【服务器】接收消息:" + clientInputStr);
			// 【响应客户端】 —— 打开输出流(出)
			DataOutputStream out = new DataOutputStream(socket.getOutputStream());
			System.out.print("【服务器】响应消息: ");
			String ServerOutputStr = new BufferedReader(new InputStreamReader(System.in)).readLine();
			out.writeUTF(ServerOutputStr);
			// 关闭流
			out.close();
			input.close();
		} catch (Exception e) {
			System.err.println("服务器run异常: " + e.getMessage());
		} finally {
			if (socket != null) {
				try {
					socket.close();
				} catch (Exception e) {
					socket = null;
					System.err.println("服务端finally异常:" + e.getMessage());
				}
			}
		}
	}
}

       2).客户端范例代码

public static final String IP_ADDR = "localhost";	//服务器地址   
public static final int PORT = 80;	//服务器端口号    
public static void main(String[] args){
    	System.err.println("启动客户端...");
    	Socket socket = null;
    	try{
    	// 创建流套接字对象并将其连接到指定主机上的指定端口号 
    	    	socket = new Socket(IP_ADDR,PORT);
        	// 【向服务器发送信息】 —— 打开输出流(出)
	    	DataOutputStream out = new DataOutputStream(socket.getOutputStream());
    	System.out.print("【客户端】发送消息: ");    
	    	String clientStr = new BufferedReader(new InputStreamReader(System.in)).readLine();    
	    	out.writeUTF(clientStr);
	    	// 【接收服务器的响应信息】 —— 打开输入流(入)
	    	DataInputStream input = new DataInputStream(socket.getInputStream());    
	    	String ServerText = input.readUTF();     
	    	System.out.println("【客户端】获取响应消息: " + ServerText);   
	    	//关闭流
	    	out.close();
	    	input.close();  
    	} catch (Exception e) {  
    		System.err.println("客户端异常:" + e.getMessage());   
    	} finally {  
	    	if (socket != null) {  
	    		try {  
	    			socket.close();  
		    	} catch (IOException e) {  
    	socket = null;   
		    		System.err.println("客户端 finally 异常:" + e.getMessage());   
            	 }
	    	}
    	}
}

       先启动 Server 端,进入一个死循环以便一直监听某端口是否有连接请求。然后运行 Client 端,客户端发出连接请求,服务端监听到这次请求后向客户端发回接受消息,连接建立,启动一个线程去处理这次请求,然后继续死循环监听其他请求。客户端输入字符串后按回车键,向服务器发送数据。服务器读取数据后回复客户端数据。这次请求处理完毕,启动的线程消亡。

       结果如下图所示

三、半关闭Socket

       服务器端处理信息时时逐行进行处理的,在一些协议里,通信的数据单位可能是多行,例如响应内容即包含很多数据。在这种情况下,我们需要解决一个问题:Socket输出流如何表示输出已经结束?

       Java-IO流中,如果想要表示输出已经结束,可以通过关闭输出流来实现。但在网络通信中,不能通过关闭输出流来表示输出已经结束,因为在关闭输出流时,对应的Socket也会被关闭,导致无法从输入流中读取数据。

       为了解决上述问题,Socket提供了如下两个半关闭的方法:只关闭Socket的输入流或者输出流,用于表示输出数据已经发送完毕。

shutdownInput():关闭Socket的输入流,程序还能使用输出流输出数据
shutdownOutput():关闭Socket的输出流,程序还能使用输入流读取数据

       当调用shutdownInput或shutdownOutput方法关闭Socket的输入流或输出流之后,该Socket处于“半关闭”状态,该Socket无法再次打开输出流或输入流,因此这种做法不适合需要保持持久通信状态的交互式应用。

       Socket可以通过isInputShutdown()方法判断该Socket是否处于半读状态,通过isOutputShutdown()方法判断该Socket是否处于半写状态。如果两者都关闭了,该Socket实例依然没有被关闭,只是即不能输出数据也不能读取数据。范例程序如下所示。

       1).服务器范例代码

public static final int PORT = 80;	//服务器端口号
public static final int TIME_OUT = 10*1000;	//连接超时时间
public static void main(String[] args) throws IOException {
	System.err.println("服务器已经启动...");
	ServerSocket serverSocket = new ServerSocket(PORT);
	serverSocket.setSoTimeout(TIME_OUT);
	Socket socket = serverSocket.accept();
	DataInputStream input = new DataInputStream(socket.getInputStream());
	DataOutputStream out = new DataOutputStream(socket.getOutputStream());
	// 【接收客户端数据】 —— 打开输入流(入)
	String clientInputStr = input.readUTF();
	System.out.println("【服务器】接收消息:" + clientInputStr);	
	// 关闭Socket输出流
	socket.shutdownOutput();
	// 判断Socket是否关闭
	System.out.println("【服务器控制台】关闭Socket输出流之后,该Socket是否关闭?"+socket.isClosed());
	try{
		System.out.print("【服务器】响应消息: ");
		String ServerOutputStr = new BufferedReader(new InputStreamReader(System.in)).readLine();
		out.writeUTF(ServerOutputStr);
	}catch(IOException e){
		System.err.println("Socket Output Is Shutdown");
	}
	//关闭流
   	out.close();
   	input.close();
socket.close();
}

       2).客户端范例代码

public static final String IP_ADDR = "localhost"; // 服务器地址
public static final int PORT = 80; // 服务器端口号
public static void main(String[] args) throws IOException {
	try {
		Socket socket = new Socket(IP_ADDR, PORT);   
		// 向服务器发送数据
		DataOutputStream out = new DataOutputStream(socket.getOutputStream());
	    	System.out.print("【客户端】发送消息: ");    
	     String clientStr = new BufferedReader(new InputStreamReader(System.in)).readLine();    
	     out.writeUTF(clientStr);
	    	// 关闭流
	    	out.close();
	    	socket.close();
	} catch (UnknownHostException e) {
		e.printStackTrace();
	} catch (IOException e) {
		e.printStackTrace();
	}
}

       输出结果如下所示

服务器已经启动...
【服务器】接收消息:你好
【服务器控制台】关闭Socket输出流之后,该Socket是否关闭?false
【服务器】响应消息: 你好,我已经接收到你的消息了
Socket Output Is Shutdown

       注意:即使半关闭了连接,或将连接的两半都关闭了,使用结束后仍然需要关闭该Socket。Shutdown方法只影响Socket的流,并不释放与Socket关联的资源,如所占用的端口等。

四、Socket编程的额外知识

       4.1 BIO 编程:同步阻塞的编程方式。

       BIO编程方式通常是在JDK1.4版本之前常用的编程方式。编程实现过程为:首先在服务端启动一个ServerSocket来监听网络请求,客户端启动Socket发起网络请求,默认情况下ServerSocket回建立一个线程来处理此请求,如果服务端没有线程可用,客户端则会阻塞等待或遭到拒绝。

       同步并阻塞,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,当然可以通过线程池机制改善。

       BIO方式适用于连接数目比较小且固定的架构,这种方式对服务器资源要求比较高,并发局限于应用中,JDK1.4以前的唯一选择,但程序直观简单易理解。

       使用线程池机制改善后的BIO模型图如下:

4.2 NIO 编程:同步非阻塞的编程方式。

       NIO本身是基于事件驱动思想来完成的,其主要想解决的是BIO的大并发问题,NIO基于Reactor,当socket有流可读或可写入socket时,操作系统会相应的通知引用程序进行处理,应用再将流读取到缓冲区或写入操作系统。也就是说,这个时候,已经不是一个连接就要对应一个处理线程了,而是有效的请求,对应一个线程,当连接没有数据时,是没有工作线程来处理的。

       NIO的最重要的地方是当一个连接创建后,不需要对应一个线程,这个连接会被注册到多路复用器上面,所以所有的连接只需要一个线程就可以搞定,当这个线程中的多路复用器进行轮询的时候,发现连接上有请求的话,才开启一个线程进行处理,也就是一个请求一个线程模式。

       在NIO的处理方式中,当一个请求来的话,开启线程进行处理,可能会等待后端应用的资源(JDBC连接等),其实这个线程就被阻塞了,当并发上来的话,还是会有BIO一样的问题

4.3 AIO编程:Asynchronous IO: 异步非阻塞的编程方式。

       与NIO不同,当进行读写操作时,只须直接调用API的read或write方法即可。这两种方法均为异步的,对于读操作而言,当有流可读取时,操作系统会将可读的流传入read方法的缓冲区,并通知应用程序;对于写操作而言,当操作系统将write方法传递的流写入完毕时,操作系统主动通知应用程序。即可以理解为,read/write方法都是异步的,完成后会主动调用回调函数。

       在JDK1.7中,上面部分内容被称作NIO.2,主要在java.nio.channels包下增加了下面四个异步通道:AsynchronousSocketChannel、AsynchronousServerSocketChannel、AsynchronousFileChannel、AsynchronousDatagramChannel

猜你喜欢

转载自blog.csdn.net/Rao_Limon/article/details/90033679
今日推荐