UDP数据报套接字编程
API介绍
DatagramSocket
DatagramSocket是UDP的Socket,用于发送和接收数据报.
操作系统中有一类文件,就叫做socket文件(普通文件/目录文件:在硬盘上的)
socket文件:抽象的表示了网卡这样的硬件设备
DatagramSocket就是对socket文件进行读写,也就是借助网卡发送数据.
通过网卡发送数据,就是写socket文件;通过网卡读取数据,就是读socket文件.
DatagramSocket构造方法:
方法签名 | 方法说明 |
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机 任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机 指定的端口(一般用于服务端) |
DatagramSocket方法:
方法签名 | 方法说明 |
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有收到数据报,该 方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramPacket
UDP数据报,每次接收数据的基本单位,就是一个UDP数据报
DatagramPacket是UDP Socket发送和接收的数据报.
DatagramPacket构造方法:
方法签名 | 方法说明 |
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的 数据保存在字节数组(第一个参数buf)中,接收指定 长度(第二个参数length) |
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) |
构造一个DatagramPacket以用来发送数据包,发送的 数据为字节数组(第一个参数buf)中,从0到指定长度 (第二个参数length).address指定目的主机IP和端口号 |
DatagramPacket方法:
方法签名 | 方法说明 |
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址; 或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号; 或从发送的数据报中,获取接收端主机的端口号 |
byte[] getData() | 获取数据报中的数据 |
构造UDP发送的数据报时,需要传入SocketAddress,该对象可以使用InetSocketAddress来创建.
InetSocketAddress
InetSocketAddress (SocketAddress的子类) 构造方法:
方法签名 | 方法说明 |
InetSocketAddress(InetAddress addr, int port) | 创建一个Socket地址,包含IP地址和端口号 |
代码示例
UDP Echo Server
下面以一个简单的回显服务器作为代码示例的程序(回显服务器:客户端发啥请求返回啥响应)
警告:文本解析巨长无比
import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.SocketException; public class UdpEchoServer { /* 最开始的一步:先创建DdtagramSocket对象 接下来需要操作网卡,操作网卡是通过socket对象完成的 socket对象是在内存中的,通过这个来影响网卡(类似遥控) */ private DatagramSocket socket = null; /* 一个主机上的一个端口号只能被一个进程绑定,反过来,一个进程可以绑定多个端口 创建对象时,手动指定一个端口号(在运行服务器程序的时候,主动指定端口) 程序一启动就需要关联上/绑定上一个操作系统中的端口号 端口号也是一个整数,用来区分一个主机上进行网络通信的程序. */ //参数时服务器要绑定的端口 public UdpEchoServer(int port) throws SocketException { /* SocketException是网络编程中的常见异常,通常表示 socket创建失败,比如端口号被别的进程占用,就会失败 */ socket = new DatagramSocket(port); } //使用这个方法启动服务器 public void start() throws IOException { System.out.println("服务器启动"); while(true) { //反复的,长期的执行针对客户端请求处理的逻辑.(不停的收到请求,返回响应) //一个服务器,运行的过程中,要做的事情,主要是三个核心环节. //1.读取请求,并解析. (一个服务器单位时间处理的请求返回响应越多,服务器越厉害) DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096); /* receive就从网卡中读出了一个数据报,就被放入了requestPacket对象中. 其中UDP数据报的载荷部分就被放到requestPacket内置的字节数组中了. 除了UDP报头,还有其它信息,比如收到的数据:源IP */ //如果执行到这个地方,没有客户端请求的话,就会阻塞. socket.receive(requestPacket); //转成字符串,方便逻辑处理(前提:后续客户端发的就是一个文本字符串) String request = new String(requestPacket.getData(), 0, requestPacket.getLength()); //有效长度 //2.根据请求,计算出响应(对于回显服务器,这一步什么都不用做) String response = process(request); //3.把响应写回给客户端 //此时需要告知网卡,要发的内容是啥,要发给谁 //requestPacket是客户端发来的数据报. //通过getSocketAddress()得到InetAddress对象,这个对象就包含了和服务器的通信对端(对应客户端IP) //此时就起到了把消息返回给客户端的效果(还可以看到UDP是无连接通信,socket不包含对端IP,端口) DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, requestPacket.getSocketAddress()); socket.send(responsePacket); //记录日志,方便观察程序执行效果 System.out.printf("[%s : %d] req: %s, resq: %s\n", requestPacket.getAddress() requestPacket.getPort(), request, response); } } /* 上述代码中,可以看到UDP是无连接的通信~UDPsocket自身不保存对端的IP和端口 而是在每个数据报中有一个.另外代码中也"建立连接","接受连接"的擦做 面向数据报,send和receive都是以DatagramPacket为主 */ //根据请求计算响应 public String process(String request) { return request; } public static void main(String[] args) throws IOException { //指定任何想要的端口,但要确保这个端口在机器上未被其它进程占用 UdpEchoServer server = new UdpEchoServer(9090); server.start(); } }
UDP Echo Client
import java.io.IOException; import java.net.DatagramPacket; import java.net.DatagramSocket; import java.net.InetAddress; import java.net.SocketException; import java.util.Scanner; public class UdpEchoClient { private DatagramSocket socket = null; private String serverIp; private int serverPort; //服务器的ip和服务器的端口 (因为发起请求的前提就是知道服务器在哪). public UdpEchoClient(String ip, int port) throws SocketException { serverIp = ip; serverPort = port; //这个new操作,就不再指定端口了.让系统分配一个空闲端口 socket = new DatagramSocket(); } /* 在服务器中,在代码中需要手动指定端口号,才能保证端口始终固定 如果不手动指定,依赖系统自动分配导致服务器重启之后,端口号就 变了,客户端可能找不到服务器在哪了 服务器这个机器,是在程序员手里的,是可控的. 程序员能知道服务器有哪些端口被使用,客户端是在普通用户的机器上 */ //让这个客户端反复的从控制台读取用户输入的内容,把这个内容构成UDP请求,发送给服务器 //最终显示在客户端的屏幕上 public void start() throws IOException { Scanner sc = new Scanner(System.in); System.out.println("客户端启动!"); while(true) { //1.从控制台读取用户输入内容 System.out.println("->"); //命令提示符,提示用户要输入的字符串 String request = sc.next(); //从控制台读取,最好使用next而不是nextLine //2.构造请求对象,并发给服务器 DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(serverIp), serverPort); socket.send(requestPacket); //3.读取服务器的响应,解析出响应内容. DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096); socket.receive(responsePacket); String response = new String(responsePacket.getData(), 0, responsePacket.getLength()); //4.显示到屏幕上 System.out.println(response); } } public static void main(String[] args) throws IOException { UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090); client.start(); } }
让我们结合一下服务器和客户端,来看一下执行的流程:
1.服务器启动,启动之后,立即进入while循环,执行到receive(),进入阻塞等待,这时还未收到客户端的请求.
2.客户端启动,启动之后进入while循环,执行到输入那里堵塞,此时用户未输入内容.
3.用户在客户端输入字符串回车.此时阻塞解除,next会返回刚才的内容.基于用户输入内容,构造一个DatagramPacket对象,并进行send.send执行完之后,继续receive操作,等待服务器响应数据(此时服务器还没响应,就会阻塞).
4.服务器收到请求之后,就会从receive阻塞中返回.返回之后,就会根据读到的DatagramPacket对象,构造String request,通过process方法构造出一个String response,再根据response构造一个DatagramPacket表示响应对象.再通过send来进行发送给客户端.(执行这个的过程中,客户端也在阻塞等待).
5.客户端从receive中返回执行,就能获取到服务器返回的响应,并且打印到控制台上,与此同时,服务器进入下一个环节,也就是进入到第二轮receive阻塞.等待下一个请求了.
UDP Dict Server
我们之前写的服务器是回显服务器,我们来扩展以下,写一个处理简单英译汉功能的服务器.
import java.io.IOException; import java.net.SocketException; import java.util.HashMap; import java.util.Map; public class UdpDictServer extends UdpEchoServer{ private Map<String, String> dict = new HashMap<>(); public UdpDictServer(int port) throws SocketException { super(port); dict.put("cat", "小猫"); dict.put("dog", "小狗"); dict.put("fuck", "我爱你"); //可以在这里添加千千万万个单词.使每一个单词都有对应的翻译 } @Override public String process(String request) { //把请求对应单词的翻译,给返回回去 return dict.getOrDefault(request, "该单词没有查询到!"); } public static void main(String[] args) throws IOException { UdpDictServer server = new UdpDictServer(9090); server.start(); //在此处会除法多态 } }
TCP流套接字编程
和刚才的UDP类似.但是TCP是面向字节流的,传输的基本单位是字节
API介绍
ServerSocket
这个Socket类对应到网卡,只能给服务器使用.
ServerSocket是创建TCP服务端Socket的API.
ServerSocket构造方法:
方法签名 | 方法说明 |
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
ServerSocket方法:
方法签名 | 方法说明 |
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端 连接后,返回一个服务端socket对象,并基于该 Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
Socket
对应到网卡,既可以给客户端使用,也可以给服务器使用.
Socket是客户端Socket,或服务端接收到客户端建立的连接(accept方法)的请求后,返回的服务端Socket.
不管是客户端还是服务端Socket,都是双方建立连接之后,保存对端信息,及用来与对方收发数据的.
Socket构造方法:
方法签名 | 方法说明 |
Socket(String host, int port) | 创建一个客户端流套接字Socket,并与对应IP的主机 上,对应端口的进程进行连接 |
Socket方法:
方法签名 | 方法说明 |
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回此套接字的输出流 |
代码示例
TCP Echo Server
import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.ServerSocket; import java.net.Socket; import java.util.Scanner; public class TcpEchoServer { private ServerSocket serverSocket = null; //这个操作就会绑定端口号 public TcpEchoServer(int port) throws IOException { serverSocket = new ServerSocket(port); } //启动服务器 public void start() throws IOException { System.out.println("服务器启动!"); while(true) { //这是个建立链接的过程,就相当于接地阿努好的时候这边拨完号打过去,另一边在响铃 //通过accept()操作接听电话,然后进行通信 //clientSocket负责后续通信交互 Socket clientSocket = serverSocket.accept(); /*accept可能会产生阻塞.没有客户端,就会阻塞.有客户端的时候再处理 有一个客户端来了, accept一次就能返回一次. 有多个客户端连过来了,accept就会执行多次 */ processConnection(clientSocket); } } //通过这个方法来处理一个连接的逻辑 private void processConnection(Socket clientSocket) { System.out.printf("[%s:%d] 客户端上线!\n", clientSocket.getInetAddress(), clientSocket.getPort()); //接下来就可以读取请求,根据请求计算响应,返回响应这三步走了. //Socket对象包含两个字节流对象,可以把这两字节流对象获取到,完成后续的读写 /* inputStream是从网卡中读,OutputStream是向网卡中写 TCP是面向字节流的.和文件操作以一样的类和方法来完成tcp socket的读写 */ try(InputStream inputStream = clientSocket.getInputStream(); OutputStream outputStream = clientSocket.getOutputStream()) { //一次连接中,可能会涉及到多次请求/响应 while(true) { //1.读取请求并解析..为了读取方便,直接使用Scanner Scanner sc = new Scanner(inputStream); //客户端退出的时候就会触发Tcp的"断开连接"流程. //服务器这边的代码也会感知到,对应Scanner就在hasNext()处返回false. if(!sc.hasNext()) { //读取完毕,客户端下线. System.out.printf("[%s:%d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort()); break; } //这个代码暗含一个约定,客户端发过来的请求,得是文本数据. next读的时候要督导空白符才会结束. // 因此就要客户端发来的请求必须带有空白符结尾.比如\n或者空格. String request = sc.next(); //2.根据请求计算相应 String response = process(request); //3.把响应写回客户端 // 通过这种方式可以写回,但是这种方式不方便给返回的响应中添加\n //outputStream.write(response.getBytes(), 0, response.getBytes().length); // 也可以给outputStream套上一层,完成更方便的写入. PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(response); //这里还需要加一个"刷新缓冲区"的操作 printWriter.flush(); //日志,打印当前的请求详细. System.out.printf("[%s:%d] req: %s, resq: %s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response); } } catch (IOException e) { e.printStackTrace(); } finally { //在finally中加上close操作,确保当前socket被正确关闭 /* tcp的clientSocket是每个客户端都有一个.随着客户端 越来越多.这里消耗的socket也会越来越多(如果不释放,就可能把文件操作符表占满) */ try { clientSocket.close(); } catch (IOException e) { e.printStackTrace(); } } } private String process(String request) { return request; } public static void main(String[] args) throws IOException { TcpEchoServer server = new TcpEchoServer(9090); server.start(); } }
Tcp Echo Client
import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.PrintWriter; import java.net.Socket; import java.util.Scanner; public class TcpEchoClient { private Socket socket = null; //要和服务器通信, 就需要先知道,服务器所在的位置 public TcpEchoClient(String serverIp, int serverPort) throws IOException { //这个new操作完成之后,就完成了tcp连接的建立 socket = new Socket(serverIp, serverPort); } public void start() { System.out.println("客户端启动!"); try (InputStream inputStream = socket.getInputStream(); OutputStream outputStream = socket.getOutputStream()) { Scanner scannerConsole = new Scanner(System.in); Scanner scannerNetwork = new Scanner(inputStream); PrintWriter printWriter = new PrintWriter(outputStream); while(true) { //1.从控制台中读取字符串 System.out.println("->"); if(!scannerConsole.hasNext()) { break; } String request = scannerConsole.next(); //2.把请求发给服务器 //使用println带上换行, 后续服务器读取请求,就使用scanner.next()读取 printWriter.println(request); printWriter.flush(); // 不要忘记flush, 确保数据是真的发出去了 //3.从服务器中读取响应 String response = scannerNetwork.next(); //4.把响应打印出来 System.out.println(response); } } catch (IOException e) { e.printStackTrace(); } } public static void main(String[] args) throws IOException { TcpEchoClient client = new TcpEchoClient("127.0.0.1", 9090); client.start(); } }
服务器引入多线程
如果只是单个线程,无法同时响应多个客户端.
虽然第二个客户端和服务器在内核层面上建立tcp连接了,但是应用程序这里无法把连接拿到应用程序里处理(人家给你打电话,电话一直响,但是不接)
这个问题,不是tcp引起的,而是因为代码结构不够好,两层循环所引起.UDP服务器只有一层循环.就不涉及该问题.之前UDP服务器天然可处理多个客户端请求.
所以主要的问题就是无法既能给客户端循环提供服务,又能去快速调用到第二个accept.
这时我们就想到了多线程的方法,此处给每个客户端都分配一个线程.
//启动服务器 public void start() throws IOException { System.out.println("服务器启动!"); while(true) { Socket clientSocket = serverSocket.accpet(); //每次来一个客户端,就创建一个新线程.每次走一个,就销毁一个线程 Thread t = new Thread(() -> { //此时,就把processConnection交给新线程负责了. //主循环就会快速执行完一次后,回到accept这里阻塞等待新的客户端来 processConnection(clientSocket); }); t.start(); } }
但是,我们不由得要思考一个新的问题,就是引入多线程,免不了频繁的线程销毁与创建,在资源申请与释放上开销比较大.所以有什么更好的方法可以解决呢>
服务器引入线程池
为了避免频繁创建销毁线程,也可以引入线程池.
//启动服务器 public void start() throws IOException { System.out.println("服务器启动!"); ExecutorService service = Executors.newCachedThreadPool(); while(true) { Socket clientSocket = serverSocket.accept(); //使用线程池来解决上述问题 service.submit(new Runnable() { @Override public void run() { processConnection(clientSocket); } }); } }
线程,解决的是线程的频繁销毁创建问题.如果,当前的场景是线程频繁的创建,而不是销毁呢?
就比如像游戏服务器这种,服务器可能处理的时间非常长.
此时如果继续使用线程池/多线程,就会导致服务器一下积累大量线程,对于服务器的负担也非常重.
长短连接
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是长连接还是短连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接.也就是说,短连接只能一次收发数据.
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接.也就是说, 长连接可以多次收发数据.
对比以上长短连接,两者区别如下:
建立连接,关闭连接的耗时:短连接每次请求,响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求,响应都可以直接传输.相对来说建立连接,关闭连接也是要耗时的,长连接效率更高.
主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发.
两者的使用场景不同:短连接使用于客户端请求频率不高的场景,如浏览网页等.长连接适用于客户端和服务端通信频繁的场景,如聊天室,实时游戏等.
扩展了解:
基于BIO(同步阻塞IO)的长连接会一直占用系统资源.对于并发很高的服务端系统来说,这样的消耗是不能承受的.
由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在一个线程中运行.
一次阻塞等待对应着一次请求,响应,不停处理也就是长连接的特性:一直不关闭连接,不停的处理请求.
实际应用时,服务端一般是基于NIO(即同步非阻塞IO)来实现长连接,性能可以极大的提升.