Java网络编程套接字
一. 网络编程中的基本概念
1.1 发送端和接收端
在⼀次⽹络数据传输时:
发送端:数据的发送⽅进程,称为发送端。发送端主机即⽹络通信中的源主机。
接收端:数据的接收⽅进程,称为接收端。接收端主机即⽹络通信中的⽬的主机。
收发端:发送端和接收端两端,也简称为收发端。
注意:发送端和接收端只是相对的,只是⼀次⽹络数据传输产⽣数据流向后的概念。
1.2 请求和响应
⼀般来说,获取⼀个⽹络资源,涉及到两次⽹络数据传输:
• 第⼀次:请求数据的发送
• 第⼆次:响应数据的发送。
1.3 客⼾端和服务端
服务端:在常⻅的⽹络数据传输场景下,把提供服务的⼀⽅进程,称为服务端,可以提供对外服务。
客⼾端:获取服务的⼀⽅进程,称为客⼾端。
对于服务来说,⼀般是提供:
• 客⼾端获取服务资源
• 客⼾端保存资源在服务端
二. Socket套接字
我们用到的网络编程套接字实际上是一组网络编程的函数的统称,是传输层给应用层提供的编程接口(一组类/方法)
传输层涉及到两种主要的协议:TCP 协议 和 UDP 协议
针对上述这两种协议,java提供了两组不同的 Socket函数
其中 TCP 协议 和 UDP 协议 在使用时的特性差异较大:
UDP 协议:无连接,不可靠传输,面向数据报,全双工
TCP 协议:有连接,可靠传输,面向字节流,全双工
下面我们来了解这些特性的含义:
- 连接:网络连接--抽象的概念--即通信双方的主机,互相保存了对方的核心信息(IP/端口号)。抽象的连接---本质上就是保存了对方的关键信息。而断开连接就是把对方的关键信息删了
所以对于TCP协议来说,它是有连接的---TCP在正式通信之前,就需要让通信双方保存对方的关键信息,在通信完毕以后再删除对方的关键信息
对于UDP来说,它是无连接的,直接上来就发送
- 可靠/不可靠传输:在网络通信的过程中,是可能产生丢包的(网络世界中,每一个路由器/交换机,单位时间能够传输的数据量是有限的,如果传输的数量超过上限,就有可能发生丢包),这是客观现象;而可靠传输就是在对抗丢包,尽可能的把数据报传输给对方,就算不能保证100%送达,也能知道接收方是否收到了数据报。不可靠传输就不会考虑丢包问题。两者没有明确的好坏之分,可靠传输更偏向于保证数据传输的可靠性,不保证传输效率,而不可靠传输更偏向于高效率的传输
- 面向字节流:特点与读写文件类似,读写方式非常灵活(使用InputStream和OutputStream)
- 面向数据报:每一次读写要以一个UDP数据报为单位
- 全双工:一个通信,可以双向传输
- 半双工:一个通信,只能单向传输
补:关于Socket这个词,可以有很多含义:
1. 网络编程套接字Socket-->网络编程中要用到的传输层的API统称
2. Socket对象---相当于对“网卡”这个设备的抽象,同时也相当于一种特殊的文件,后续我们操作Socket对象,就能操作网卡
2.1 UDP数据报套接字编程
UDP协议对应的 Socket套接字 主要需要使用 DatagramSocket类 和 DatagramPacket 类
- DatagramSocket 类用于创建 UDP 套接字,负责发送和接收数据报,它可以指定本地端口,也可以由系统自动分配端口。--用来构建Socket对象
DatagramSocket 的构造方法
DatagramSocket 的方法
- DatagramPacket 类用于封装 UDP 数据报,包含了要发送或接收的数据、目标地址(发送时)以及源地址(接收时)等信息。通过这两个类的配合,就能够完成基于 UDP 协议的网络数据传输。DatagramPacket 是传输UDP传输数据中的基本单位(数据报)
DatagramPacket 的构造方法
DatagramPacket 的方法
构造UDP发送的数据报时,需要传⼊SocketAddress ,该对象可以使⽤InetSocketAddress
来创建。
InetSocketAddress
InetSocketAddress ( SocketAddress 的⼦类)构造⽅法:
下面我们就用UDP协议对应的套接字来编写一个回显服务器
回显服务器:客服端给服务器发送一个数据,服务器都可以原封不动的返回给客服端
public class UdpEchoServer {private DatagramSocket socket=null;public UdpEchoServer(int port) throws SocketException {socket=new DatagramSocket(port); //对于服务器我们需要手动设置一个端口号(要与这个主机其它的端口号不同),// IP地址不需要--因为服务器的IP就是所在主机的IP}public void start() throws IOException {System.out.println("Server start!");//此处的循环是在不停的处理客服端发来的请求,像服务器这样的程序,通常是24小时持续运转的while(true){// 1.读取请求并解析DatagramPacket requestPacket=new DatagramPacket(new byte[1024],1024);//这里的byte数组是一个输出型参数,是为发来的数据报手动分配异常socket.receive(requestPacket);//用来接受客户端发来的请求数据报String request=new String(requestPacket.getData(),0, requestPacket.getLength());//将传来的数据报中的内容(存在字符数组的)转成字符串,方便后续操作//注意这里不能用responsePacket.getData().length--这是获取的是整个数组的长度,而不是我们需要的有效数据的长度// 2.根据请求计算响应(这里通常是一个复杂的过程,由于我们写的是一个回显服务器,所以简单)String response=process(request);// 3.把响应换返回客服端DatagramPacket responsePacket=new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());//这时构造的DatagramPacket数据报对象还要传入我们所要返回响应客户端的的IP地址和端口号--确定要给给哪个客服端返回响应//如何获得客服端的IP地址和端口号--在客服端给服务器发送的请求在就已经包含--requestPacket.getSocketAddress()直接获取socket.send(responsePacket);//打印日志System.out.printf("[%s:%d] req: %s, resp: %s\n", requestPacket.getAddress().toString(),requestPacket.getPort(), request, response);}}public String process(String request){return request;}public static void main(String[] args) throws IOException {UdpEchoServer udpEchoServer=new UdpEchoServer(9090);udpEchoServer.start();}
}
客服端:
public class UepEchoClient {private DatagramSocket socket=null;private String serverIp;private int serverPort;public UepEchoclient(String serverIp,int serverPort) throws SocketException {this.serverIp=serverIp;this.serverPort=serverPort;//记录服务器的IP地址和客服端(服务器的IP和端口号需要程序员手动输入,告知客服端对应服务器的IP和端口号)socket=new DatagramSocket();//客服端不需要我们手动指定端口号--系统自动分配空闲的端口号}public void start() throws IOException {//用户通过控制台,输入字符串,把字符串发给服务器,从服务器中读取响应Scanner scanner=new Scanner(System.in);System.out.println("client start!");while (true){System.out.println("->");String request=scanner.next();if(request.equals("exit")){break;//用户输入 exit --退出客户端}//构造UDP数据报,发送请求给服务器DatagramPacket requestPacket=new DatagramPacket(request.getBytes(),request.getBytes().length, InetAddress.getByName(this.serverIp),this.serverPort);socket.send(requestPacket);//接受来自于服务器返回的响应并解析DatagramPacket responsePacket=new DatagramPacket(new byte[1024],1024);socket.receive(responsePacket);String response=new String(responsePacket.getData(),0,responsePacket.getLength());//注意这里不能用responsePacket.getData().length--这是获取的是整个数组的长度,而不是我们需要的有效数据的长度System.out.println(response);}}public static void main(String[] args) throws IOException {UepEchoClient uepEchoClient=new UepEchoClient("192.168.0.194",9090);uepEchoClient.start();}}
上述客服端-服务器交互的流程:客服端--->服务器--->客服端
网络编程--> 跨主机通信,而上面我们写的客服端和服务器只能在同一片局域网才能进行跨主机(没有云服务器)
由于UDP是无连接的,所以它是在传输数据报时--new DatagramPacket() --才会传入对端的信息(与UDP不同,TCP是在创建Socket对象时就传入对端的信息--有连接)
下面我们来写一个有翻译功能的服务器,输入英文,返回中文:下面的服务器是直接继承的我们上面写的服务器--减少代码量
public class UepDictServer extends UdpEchoServer{private Map<String,String> dict=new HashMap<>();public UepDictServer(int port) throws SocketException {super(port);dict.put("hello", "你好");dict.put("world", "世界");dict.put("cat", "小猫");dict.put("dog", "小狗");dict.put("pig", "小猪");}@Overridepublic String process(String request) {return dict.getOrDefault(request, "没有找到该单词");}public static void main(String[] args) throws IOException {UepDictServer uepDictServer=new UepDictServer(9090);uepDictServer.start();}
}
2.2 TCP流套接字编程
TCP协议对应的 Socket套接字 主要需要使用 ServerSocket 类(只用于服务器)和 Socket 类(用于客户端和服务器)
ServerSocket 类-- 是Java中用来TCP服务端用来监听客服端连接请求的核心类,它的核心作用是“等待并接受客服端的连接请求”,而非直接与客服端进行数据传输(这是Socket 类的作用)
ServerSocket 类的构造方法
ServerSocket 类 中的方法
Socket 类--Socket 是客⼾端Socket,或服务端中接收到客⼾端建⽴连接(accept⽅法)的请求后,返回的服务端Socket。 不管是客⼾端还是服务端Socket,都是双⽅建⽴连接以后,保存的对端信息,及⽤来与对⽅收发数据 的。一个Socket 类对象就对应了与之进行连接的客服端
Socket 类的构造⽅法
Socket 类中的方法
举个列子:比如 ServerSocket就像一个在饭店外面发传单引客的工作人员,而Socket就像在饭店中工作的服务员,由ServerSocket将顾客引到饭店中(此时ServerSocket任务完成,再次去引客),而Socket才是真正和顾客进行沟通交流的人
下面我们来写一个基于TCP协议的回显服务器
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("服务端上线");ExecutorService executorService= Executors.newCachedThreadPool();//这里我们采用线程池的方式来使用多线程//但是这里要注意不能使用FixedThreadPool,线程数目固定while(true){Socket socket=serverSocket.accept();//建立连接成功,返回一个新new的Socket对象(每一个Socket对象都对应了与之建立连接的客服端)executorService.submit(()->{try {processConnection(socket);} catch (IOException e) {throw new RuntimeException(e);}});}}public void processConnection(Socket socket) throws IOException {//try(InputStream inputStream=socket.getInputStream();OutputStream outputStream=socket.getOutputStream();){Scanner sc=new Scanner(inputStream);PrintWriter writer=new PrintWriter(outputStream);while (true){//处理与一个客服端连接的多个请求if(!sc.hasNext())//判断客服端是否下线{System.out.println("客服端下线");break;}String requst=sc.next();String response=process(requst);writer.println(response);writer.flush();//冲刷缓冲区。防止发送数据长时间无响应System.out.printf("[%s,%d],respect:%s,response:%s\n",socket.getInetAddress(),socket.getPort(),requst,response);}}catch (IOException e){e.printStackTrace();}finally {socket.close();//一次连接,记得关闭Socket文件}}public String process(String requst){return requst;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer=new TcpEchoServer(9090);tcpEchoServer.start();}}
在TCP协议下,服务端和客户端建立连接不是在调用 serverSocket.accept( )时,而是在这之前由客户端在请求连接(new Socket)后在操作系统内核中来完成的,服务器这边不用做任何处理,就可以配合客户端完成连接,但是服务器要把建立好的连接拿到应用程序中来(即调用serverSocket.accept( ))
TCP的服务器的两个while循环的作用:
start()方法中while循环的作用:持续接受新的客户端的连接
processConnection()方法中的while循环的作用:维持单个客户端的“长连接”(为了避免每完成服务端和客户端的一次交互都要建立连接的开销)
由于我们的TCP协议是面向字节流的,所以发送请求与接收响应的操作与文件IO流的操作几乎一模一样,同时我们就可以配合使用Scanner和PrintWriter来进行高效的数据交互
注意:
- 还有我们要注意的一点是:PrintWriter这个类里面内置了缓冲区,只用缓冲区满了以后才会发出数据,所以我们要在使用了PrintWriter类写东西后要去调用它的flush()方法--强制将缓冲区中未发送出去的数据立刻写入到目标输入流中(文件操作中,read/write/FileInputStream/FileOutputStream这种就不带缓冲区)
- 我们要防止出现文件泄露的情况--每当有客服端连上服务器时服务器都会创建Socket对象,而Socket对象就是一种特殊的文件,即会占用文件描述符表的一个位置,但是客服端可能会源源不断的连过来,就会创建很多个Socke文件,可能会出现文件描述符表满了而出现文件资源泄露---解决方案---在与客服端完成交互后记得关闭文件
- TCP的“连接导向”要求为每个持续连接分配独立处理资源(多线程),而UDP的“无连接”特性让服务器可通过单线程循环接收所有客户端的零散数据包,无需额外线程开销。
- 使用线程池的方式实际更加适用于客服端连接持续使用短这样的场景,这样的方式不仅不用频繁的创建和销毁线程,还能在同一时刻的线程的总数不会很多;但是如果客服端持续时间很长呢,即在同一时刻的线程数目很多(要知道我们的线程不是越多越好--影响并发效率)---优化方案--IO多路复用--即一个线程服务多个客服端(因为一个Socket对象并不是一直活跃的,在不需要传输数据时,该线程就会产生阻塞--浪费线程)
- TCP代码中的一个隐含条件--请求和响应都是带 \n 结尾的。而我们使用的Writer.println( )是隐含带有 \n 的;但是我们如果我们使用Writer.print( )时再输入\n 会用效果吗--不会--原因--Scanner.next在读取请求时会默认把 \n 去掉,就会残剩客户端/服务端不知道这个请求/响应读到哪里是结束的情况
基于TCP协议的客户端代码如下:
public class TcpEchoClient {private Socket socket=null;public TcpEchoClient(String serverIp,int serverPort) throws IOException {socket=new Socket(serverIp,serverPort);}public void start() throws IOException {System.out.println("客户端上线!!!");while (true){System.out.println("->");InputStream inputStream=socket.getInputStream();OutputStream outputStream=socket.getOutputStream();PrintWriter writer=new PrintWriter(outputStream);Scanner scanner=new Scanner(System.in);String requst=scanner.next();if(requst.equals("exit")){break;}writer.println(requst);writer.flush();Scanner sc=new Scanner(inputStream);String response=sc.next();System.out.println(response);}}public static void main(String[] args) throws IOException {TcpEchoClient tcpEchoClient=new TcpEchoClient("192.168.26.194",9090);tcpEchoClient.start();}
}
TCP协议下,服务端获得对端客户端的信息来源:通过Socket对象---当服务端通过 ServerSocket 的 accept() 方法接收到客户端的连接请求后,会得到一个与该客户端通信的 Socket 对象。通过这个 Socket 对象,服务端可以调用 getInetAddress() 方法获取客户端的IP地址,调用 getPort() 方法获取客户端的端口号等信息。
UDP协议下,服务端获得对端客户端的信息来源:通过DatagramPacket对象---当服务端使用 DatagramSocket 接收客户端发送过来的 DatagramPacket 数据包时, DatagramPacket 类提供了相关方法获取客户端的信息。