网络程序设计基础
前言:
这边文章是一篇读书笔记,是我个人在看《Java从入门到精通》(第四版)一书时整理的一个笔记。里面也有借鉴到https://blog.csdn.net/wyzidu/article/details/83826656中的相关内容。如果内容涉及侵权,望告知。后面会及时删除。
1.1 局域网与因特网
为了实现两台计算机的通信,必须用一个网络线路连接两台计算机,如下图所示:
1.2 网络协议
啥是网络协议?
网络协议就是规定了计算机之间连接的物理,机械(网线与网卡的连接规定)、电气(有效的电平范围)等特征以及计算机之间的相互寻址规则、数据发送冲突的解决、长的数据如何分段传送与接收等。就像不同国家有不同的法律一样,目前网络协议也有多种。
(1)IP协议
IP是Internet Protocol的简称,它是一种网络协议。Internet网络采用的协议是TCP/IP协议,其全称是Transmission Control Protocol/Internet Protocol。Internet依靠TCP/IP协议,在全球范围内实现不同硬件结构、不同操作系统、不同的网络系统的互联。
TCP/IP模式是一种层次结构,共分为4层,分别为应用层、传输层、网络层和链路层。如下图所示:
1.3 端口和套接字
一般而言,一台计算机只有单一的连到网络的物理连接,所有的数据都通过此连接对内、对外送达特定的计算机,这就是端口。而网络程序中设计的端口并非真实的物理存在,而是一个假想的连接设置。端口被规定为一个在0~65535之间的整数。HTTP服务一般使用80端口,FTP服务一般使用21端口。假如一台计算机提供了HTTP、FTP等多种服务,那么客户机会通过不同的端口来确定连接到服务器上的哪项服务上。如下图所示:
网络程序中的套接字(socket)用于将应用程序与端口连接起来。套接字是一个假想的连接装置,就像插座一样,用于连接电器和插口。Java将套接字抽象化为类,程序设计者只需要创建Socket类对象,即可使用套接字。如下图所示:
2. TCP程序设计基础
TCP网络程序设计是指利用Socket类编写的通信程序。利用TCP协议进行通信的两个应用程序是有主次之分的,一个称为服务器程序,另一个称为客户机程序,两者的功能和编写方法大不一样。服务器端与客户机端的交互过程如下图所示:
1. 服务器程序创建一个ServerSocket(服务器端套接字),调用accrpt()方法等待客户机来连接;
2.客户端程序创建一个Socket,请求与服务器建立连接;
3.服务器接收客户机的连接请求,同时创建了一个新的Socket与客户机建立连接,服务器继续等待新的请求。
得到一张比较详细的Socket请求过程,如下图:
2.1 InetAddress类
1 import java.net.InetAddress; 2 import java.net.UnknownHostException; 3 /** 4 * 测试InetAddress类的常用方法 5 */ 6 public class testInetAddressApi { 7 public static void main(String[] a) { 8 InetAddress ip; 9 try { 10 ip = InetAddress.getLocalHost();// 实例化对象 11 String localname = ip.getHostName();// 获取本级名 12 String localip = ip.getHostAddress();// 获取本级ip地址 13 System.out.println("本机名:"+ localname); 14 System.out.println("本机IP地址:"+ localip); 15 } catch (UnknownHostException e) { 16 e.printStackTrace(); 17 } 18 } 19 }
输出结果:
本机名:ppp-99-12-203-170.dsl.scrm01.sbcglobal.net
本机IP地址:99.12.203.170
2.2 ServerSocket类
java.net包中的ServerSocket类用于表示服务器套接字,其主要功能是等待来自网络上的请求,它可通过指定的端口来等待连接的套接字。服务器套接字一次可以与一个套接字连接。如果多台客户机同时提出连接请求,服务器套接字会将请求连接的客户机存入队列中,然后从中取出一个套接字,与服务器新建的套接字连接起来。若请求连接数大于最大容纳数,则多出的连接请求被拒绝。队列的大小默认为50。
ServerSocket类的构造函数都抛出IOException异常分别有以下几种形式:
- ServerSocket():创建非绑定服务器套接字。
- ServerSocket(int port):创建绑定到特定端口的服务器套接字。
- ServerSocket(int port, int backlog):利用指定的backlog创建服务器套接字并将其绑定到指定的本地端口号。
- ServerSocket(int port, int backlog, InetAddress bindAddress):使用指定的端口、侦听backlog和要绑定到本地IP地址创建服务器。这种情况适用于计算机上有多块网卡和多个IP地址的情况,用于可以明确规定ServerSocket在哪块网卡或IP地址上等待客户机的连接请求。
关于第三个构造函数ServerSocket(int port, int backlog),有点需要自己注意的地方:
对于第二个参数backlog,java文档是这样解释的:
The maximum queue length for incoming connection indications (a request to connect) is set to the backlog
parameter. If a connection indication arrives when the queue is full, the connection is refused.
所以一开始我是认为这个参数表示着请求待处理队列的长度。所以我就想,如果要控制客户机的连接数,是不是把backlog这个参数设置成自己想要的值就可以控制了。然后我设置成1,用两个客户机去连接,但是没有报错。这个时候就有点懵逼。上网查了一下说要把连接数设置大一点,接着我就把客户机连接数扩大45,果然这次出现了异常,就抛ConnectException了。难道是连接数太少?可是这样并没办法说服自己。后来我想这个值表示的是队列的长度,其实一开始java文档并没有说这个事请求连接数的数量。那么有可能一开始我把backlog设置成1,用两个客户机去请求连接,服务器很快就把连个连接处理完了,所以队列长度为1并没有造成大小不够。按照这个思路,第二次实验的时候,我用单独的线程处理服务器处理请求,在其处理请求的逻辑里让其休眠5秒,然后再用两个客户机去请求。这个时候果然抛了ConnectException异常。以此得以验证我的想法是没有错误的。那么用45客户机请求,我把backlog设置成45或者更大,理应也是不会出现异常的。实验之后果然是正常的。由此可知,想控制请求的连接数并不能通过设置backlog的值来达到效果。
ServerSocket类的常用方法:
方法 | 返回值 | 说明 |
accept() | Socket | 等待客户机连接。若连接,则创建一套接字 |
isBound() | boolean | 判断ServerSocket的绑定状态 |
getInetAddress() | InetAddress | 返回此服务器套接字的本地地址 |
isClosed() | boolean | 返回服务器套接字的关闭状态 |
close() | void | 关闭服务器套接字 |
bind(SocketAddress endpoint) | void | 将ServerSocket绑定到特定的地址(IP地址和端口号) |
getInetAddress() | void | 返回服务器套接字等待的端口号 |
关于accept()方法做如下备注说明,方便理解:
1. 调用ServerSocket类的accpet()方法会返回一个和客户机端Socket对象相连接的Socket对象,服务器端的Socket对象使用getOutputStream()方法获得输出流将指向客户端Socket对象使用getInputStream()方法获得的那个输入流;同样,服务器端的Socket对象使用getInputStream()方法获得的输入流将指向客户机端Socket对象使用getOutputStream()方法获得的那个输入流。也就是说,当服务器向输出流写入信息时,客户端通过相应的输入流就能读取,反之亦然。
2. accpet()方法会阻塞线程继续执行,直到接收到客户的呼叫。如果没有客户呼叫服务器,那么如下的语句中,System.out.println("连接中。。。")将不会被执行。语句如果没有客户请求,而accpet()方法没有发生阻塞,那么肯定是程序出现了问题。通常是使用了一个还在被其他程序占用的端口号,ServerSocket绑定不成功。
1 Socket s = server.accpet(); 2 System.out.println("连接中。。。");
2.3 TCP网络程序
明白了TCP程序的工作的过程,就可以开始编写TCP服务程序了。在网络编程中如果只要客户机向服务器发送消息,不要求服务器向客户机发送消息,称为单向通信。如果服务器也向客户机返回消息,则是双向通信。下面是一个简单的双向通信案例:
服务器端:
1 import java.io.IOException; 2 import java.io.InputStream; 3 import java.io.OutputStream; 4 import java.net.ServerSocket; 5 import java.net.Socket; 6 import java.net.SocketAddress; 7 8 public class TcpEchoServer { 9 private static final int BUFSIZE = 32; 10 11 public static void main(String[] args) throws IOException { 12 13 // 创建 ServerSocket 实例,并监听给定端口号 servPort 14 ServerSocket servSock = new ServerSocket(6660); 15 int recvMsgSize; 16 byte[] receiveBuf = new byte[BUFSIZE]; 17 18 while (true) { 19 // 用于获取下一个客户端连接,根据连接创建 Socket 实例 20 Socket clntSock = servSock.accept(); 21 // 获取客户端地址和端口号 22 SocketAddress clientAddress = clntSock.getRemoteSocketAddress(); 23 System.out.println("Handling client at " + clientAddress); 24 25 // 获取 socket 的输入输出流 26 InputStream in = clntSock.getInputStream(); 27 OutputStream out = clntSock.getOutputStream(); 28 29 // 每次从输入流中读取数据并写到输出流中,直至输入流为空 30 while ((recvMsgSize = in.read(receiveBuf)) != -1) { 31 out.write(receiveBuf, 0, recvMsgSize); 32 } 33 34 // 关闭 Socket 35 clntSock.close(); 36 } 37 } 38 }
客户端:
1 import java.io.IOException; 2 import java.io.InputStream; 3 import java.io.OutputStream; 4 import java.net.Socket; 5 import java.net.SocketException; 6 7 public class TcpEchoClient { 8 public static void main(String[] args) throws IOException { 9 10 // 根据参数创建 Socket 实例 11 Socket socket = new Socket("127.0.0.1", 6660); 12 13 System.out.println("Connected to server... sending echo string"); 14 15 // 获取 socket 的输入输出流 16 InputStream in = socket.getInputStream(); 17 OutputStream out = socket.getOutputStream(); 18 19 // 要发送的信息 20 String sendMsg = "这是测试请求服务端的程序。。。"; 21 22 // 将数据写入到 Socket 的输出流中,并发送数据 23 byte[] data = sendMsg.getBytes(); 24 out.write(data); 25 26 int totalBytesRcvd = 0; 27 int bytesRcvd; 28 29 // 接收返回信息 30 while (totalBytesRcvd < data.length) { 31 if ((bytesRcvd = in.read(data, totalBytesRcvd, data.length - totalBytesRcvd)) == -1) { 32 throw new SocketException("Connection closed permaturely"); 33 } 34 totalBytesRcvd += bytesRcvd; 35 } 36 37 System.out.println("Received: " + new String(data)); 38 39 // 关闭 Socket 40 socket.close(); 41 } 42 }
必要说明:
1. Socket 中的输入输出流是流抽象,可看做一个字符序列,输入流支持读取字节,输出流支持取出字节。每个 Socket 实例都维护了 一个 InputStream 和一个 OutputStream 实例,数据传输也主要依靠从流中获取数据并解析的过程。
2. ServerSocket 与 Socket 区别,ServerSocket 主要用于服务端,用于为新的 TCP 连接请求提供一个新的已连接的 Socket 实例。Socket 则用于服务端和客户端,用于表示 TCP 连接的一端。因此,服务端需要同时处理 ServerSocket 和 Socket 实例,而客户端只需要处理 Socket 实例即可。
3. 发送数据时只通过 write() 方法,接收时为何需要多个 read() 方法?
TCP 协议无法确定在 read() 和 write() 方法中所发送信息的界限,而且发送过程中可能存在乱序现象,即分割成多个部分,所以无法通过一次 read() 获取到全部数据信息。
3. UDP程序设计基础
用户数据报协议(UDP)是网络信息传输的另一种形式。基于UDP的通信和基于TCP通信不同,基于UDP的信息传输更快,但是不提供可靠的保证。使用UDP传递数据时,用户无法知道数据能否正确地到达主机,也不能确定到达目的的顺序是否和发送的顺序相同。虽然UDP是一种不可靠的协议,但是如果需要较快地传输信息,并能容忍小的错误,可以考虑使用UDP.
基于UDP通信的基本模式如下:
- 将数据打包(称为数据包),然后将数据包发往目的地。
- 接收别人发来的数据包,然后查看数据包。
下面是总结UDP程序的步骤:
发送数据包:
- 使用DatagramSocket()创建一个数据包套接字。
- 使用DatagramPacket(byte[] buf, int length, InetAddress address, int port)创建要发送的数据包。
- 使用DatagramSocket类的send()方法发送数据包。
接收数据包:
- 使用DatagramSocket(int port)创建数据包套接字,绑定到指定的端口。
- 使用DatagramPacket(byte[] buf, int length)创建字节数组来接收数据包。
- 使用DatagramPacket类的receive()方法接收UDP包。
注意:
DatagramSocket类的receive()方法接收数据时,如果还没有可以接收到数据,在正常情况下receive()方法将阻塞,一直等到网络上有数据传过来,receive()方法接收数据并返回。如果网络上没有数据发送过来,receive()方法也没有阻塞,肯定是程序有问题,大多数是使用了一个被其他程序占用的端口号。
3.1 DatagramSocket类
DatagramSocket类用于表示发送和接收数据包的套接字。该类的构造函数有:
- DatagramSocket()
- DatagramSocket(int port)
- DatagramSocket(int port, InetAddress addr)
第一种构造函数创建DatagramSocket对象,构造数据报套接字并将其绑定到本地主机上任何可用的端口。第二种构造函数创建DatagramSocket对象,创建数据报套接字并将其绑定到本地主机上指定的端口。第三种构造函数创建DatagramSocket对象,建数据报套接字并将其绑定到指定的本地地址。第三种构造函数适用于多块网卡和多个IP地址的情况。
3.2 DatagramPacket类
DatagramPacket类用来表示数据包。Datagrampacket类的构造函数有:
- DatagramPacket(byte[] buf, int length)
- DatagramPacket(byte[] buf, int length, InetAddress address, int port)
第一种构造函数创建DatagramPacket对象,指定了数据包的内存空间和大小。第二种构造函数不仅指定了数据包的内存空间和大小,还指定了数据包的目标地址和端口。在发送数据时,必须指定接收方的Socket地址和端口号,因此使用第二种构造函数可创建发送数据的DatagramPacket对象。
下面是UDP实例:
服务端:
1 import java.io.IOException; 2 import java.net.DatagramPacket; 3 import java.net.DatagramSocket; 4 import java.net.SocketException; 5 6 public class UDPEchoServer { 7 private static final int ECHOMAX = 255; 8 9 public static void main(String[] args) throws IOException { 10 // 创建数据报文 Socket 11 DatagramSocket socket = new DatagramSocket(6661); 12 // 创建数据报文 13 DatagramPacket packet = new DatagramPacket(new byte[ECHOMAX], ECHOMAX); 14 15 while (true) { 16 // 接收请求报文 17 socket.receive(packet); 18 System.out.println("Handling client at " + packet.getAddress().getHostAddress() + 19 " on port " + packet.getPort()); 20 21 // 发送数据报文 22 socket.send(packet); 23 // 重置缓存区大小 24 packet.setLength(ECHOMAX); 25 } 26 } 27 }
客户端:
运行参数配置:
1 import java.io.IOException; 2 import java.io.InterruptedIOException; 3 import java.net.*; 4 5 public class UDPEchoClient { 6 private static final int TIMEOUT = 3000; 7 private static final int MAXTRIES = 5; 8 9 public static void main(String[] args) throws IOException { 10 // 参数解析,格式 url "info" 或 url "info" 10240 11 if ((args.length < 2) || (args.length > 3)) { 12 throw new IllegalArgumentException("Parameter(s): <Server> <Word> [<Port>]"); 13 } 14 15 // 创建目标 Server IP 地址对象 16 InetAddress serverAddress = InetAddress.getByName(args[0]); 17 18 // 将需传输字符转换为字节数组 19 byte[] byteToSend = args[1].getBytes(); 20 // 获取服务端端口号,默认 10241 21 int servPort = (args.length == 3) ? Integer.parseInt(args[2]) : 6661; 22 23 // 创建 UDP 套接字,选择本地可用的地址和可用端口号 24 DatagramSocket socket = new DatagramSocket(); 25 26 // 设置超时时间,用于控制 receive() 方法调用的实际最短阻塞时间 27 socket.setSoTimeout(TIMEOUT); 28 29 // 创建发送数据报文 30 DatagramPacket sendPacket = new DatagramPacket(byteToSend, byteToSend.length, serverAddress, servPort); 31 32 // 创建接收数据报文 33 DatagramPacket receivePacket = new DatagramPacket(new byte[byteToSend.length], byteToSend.length); 34 35 // 设置最大重试次数,以减少数据丢失产生的影响 36 int tries = 0; 37 // 是否收到响应 38 boolean receivedResponse = false; 39 do { 40 // 将数据报文传输到指定服务器和端口 41 socket.send(sendPacket); 42 try { 43 // 阻塞等待,直到收到一个数据报文或等待超时,超时会抛出异常 44 socket.receive(receivePacket); 45 // 校验服务端返回报文的地址和端口号 46 if (!receivePacket.getAddress().equals(serverAddress)) { 47 throw new IOException("Received packet from an unknown source"); 48 } 49 receivedResponse = true; 50 } catch (InterruptedIOException e) { 51 tries += 1; 52 System.out.println("Timed out, " + (MAXTRIES - tries) + " more tries..."); 53 } 54 } while (!receivedResponse && (tries < MAXTRIES)); 55 56 if (receivedResponse) { 57 System.out.println("Received: " + new String(receivePacket.getData())); 58 } else { 59 System.out.println("No response -- giving up."); 60 } 61 // 关闭 Socket 62 socket.close(); 63 } 64 }
必要说明:
1. UDP服务端 与 TCP 服务端不同,TCP 对于每一个客户端请求都需要先建立连接,而 UDP 则不需要。因此,UDP 只需创建一个 Socket 等待客户端连接即可。
2. 在该 UDP 服务器的实现中,只接收和发送数据报文中的前 ECHOMAX 个字符,超出部分直接丢弃。
3. 在处理过接收到的消息后,数据包的内部长度会设置为刚处理过的消息长度,通常比初始长度要短,因此需重置缓冲区为初始长度。否则后续可能会使得缓冲区长度不断减小,使得数据包被截断。
4. 由于 UDP 提供的是尽最大可能的交付,所以在发送 Echo Request 请求时,无法保证一定可以送达目标地址和端口,因此考虑设置重传次数,若在超过最大等待时间后仍未收到回复,则重发当前请求,若重发次数超过最大重试次数,则可直接返回未发送成功。
4. UDP Socket 与 TCP Socket 区别
- UDP 保存了消息的边界信息,而 TCP 则没有。在 TCP 中需通过多次 read() 来接收一次 write() 的信息,而 UDP 中对于单次 send() 的数据,最多只需一次 receive() 调用。
- TCP 存在传输缓冲区,UDP 则无需对数据进行缓存。由于 TCP 存在错误重传机制,因此需保留数据的缓存,以便于重传操作,当调用 write() 方法并返回后,数据被复制到传输缓冲区中,数据有可能处于发送过程中或还没有发生传送。而 UDP 则不存在该机制,因此无需缓存数据,当调用 send() 方法返回后,消息处于发送过程中。
- UDP 会丢掉超过最大长度限制的数据,而 TCP 不会。
- 在 TCP 中,一旦建立连接后,对于所有数据都可以看做一个连续的字节序列。而在 UDP 中接收到的消息则可能来自于不同的源地址和端口,因此会将接收到的数据放在消息队列中,按照顺序来响应,超过最大长度的消息直接截断。Datagrampacket 所能传输的最大数据量为 65507 字节,也就是一个 UDP 报文能承载的最大数据。