【JavaEE初阶】网络编程
欢迎关注个人主页:逸狼
创造不易,可以点点赞吗~
如有错误,欢迎指出~
⽹络编程,指⽹络上的主机,通过不同的进程,以编程的⽅式实现⽹络通信(或称为⽹络数据传 输)。
socket api
Socket套接字,是由系统提供⽤于⽹络通信的技术,是基于TCP/IP协议的⽹络通信的基本操作单元。 基于Socket套接字的⽹络程序开发就是⽹络编程。
操作系统给应用程序(传输层给应用层)提供的api,就叫做socket api(这里学习Java版本的),有两组不同的api,分别为UDP和TCP两套版本
- UDP 无连接 不可靠传输 面向数据报 全双工
- TCP 有连接 可靠传输 面向字节流 全双工
- 有/无连接: 通信双方若保存了通信对端的信息(IP和端口),就是有连接,不保存就是无连接
- 可靠/不可靠传输:尽可能考虑能够到达对方就是可靠传输,完全不考虑就是不可靠传输.这个在代码中没办法直接体现,它是在内核中实现好的功能
- TCP内置了一些机制(感知到对方是否收到; 重传机制,在对方没收到时进行重试)可以保证可靠传输( 但是可靠传输要付出代价 ,TCP协议设计要比UDP复杂很多,也会损失一些传输数据的效率)
- UDP没有可靠性机制
- 面向字节流/数据报 :参数单位是字节的就是面向字节流,单位是数据包的就是面向数据报
- TCP是面向字节流的,传输过程和文件流/水流是一样的特点
- UDP是面向数据报的,传输数据的基本单位就是UDP数据报,一次发送/接收必须是完整的数据报
- 全/半双工:一个通信链路,可以发送数据,也可以接收数据就是全双工; 只能发送或只能接收就是半双工 这里写的代码都是全双工的,不考虑半双工
UDP版本socket api
通过代码不好直接操作网卡(网卡有很多不同的型号,之间提供的api都会有差别),操作系统就把网卡概念封装成socket,应用程序员就不必关注硬件的差异和细节,统一操作socket对象就能间接操作网卡; socket 可以认为是操作系统中广义文件下里的一种文件类型,这样的文件就是网卡这种硬件设备的抽象表现形式
代码部分需要实现两个程序
socket相当于网卡的遥控器,网络编程必须要操作网卡,就需要用到socket对象DatagramSocket
下面通过写一个"回显服务器(echo server)"(客户端发的请求和服务器返回的响应一致)代码示例加以理解
UDP服务器
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
public class UdpEchoServer {
private DatagramSocket socket=null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
//通过start 启动服务器的核心流程
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
//通过死循环不停的处理客户端的请求
//1.读取客户端的请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket);
//上述收到的数据,是二进制byte[]的形式体现的,后续代码如果要进行打印之类的操作 需要将其转成字符串
//构造string字符串 获取字节报的数据,从数组的0位置开始构造string,长度为字节报的长度
String request = new String(requestPacket.getData(),0, requestPacket.getLength());
//2.根据请求计算响应,由于此处是回显服务器,响应就是请求
String response = process(request);
//3.把响应写回到客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length
,requestPacket.getSocketAddress());//UDP是无连接的,所以要手动将客户端的请求的IP和端口号取出并包装到responsePacket里
socket.send(responsePacket);
//4.打印日志
System.out.printf("[%s:%d] req=%s ,resp=%s\n",requestPacket.getAddress(),requestPacket.getPort()
,request,response);//获取IP地址和端口号,请求和响应
}
}
private String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server =new UdpEchoServer(9090);
server.start();
}
}
对于一个系统来说,同一时刻,同一个协议下,一个端口号,只能被一个进程绑定(端口号就是用来区分进程的,如果有多个进程尝试绑定一个端口号,后来的进程就会绑定失败),但是一个进程可以同时绑定多个端口号(通过创建多个socket对象来实现)
比如:9090端口在udp下被一个进程绑定了,还可以在TCP下被另一个进程绑定
receive
DatagramSocket 这个对象中,不持有对方(客户端) 的 ip 和端口的. 所以进行 send 的时候,就需要在 send 的数据包里,把要发给谁这样的信息,写进去,才能够正确的把数据进行返回
socket在使用完之后需要关闭,此处代码中,socket生命周期整个进程一样长,就算没有close,进程关闭时也会释放文件描述符表里的所有内容,相当于close了
UDP客户端
import java.io.IOException;
import java.net.*;
import java.util.Scanner;
public class UdpEchoClient {
private DatagramSocket socket = null;
private String serverIP;
private int serverPort;
public UdpEchoClient(String serverIP ,int serverPort) throws SocketException {
socket =new DatagramSocket();
this.serverIP=serverIP;
this.serverPort =serverPort;
}
public void start() throws IOException {
System.out.println("启动客户端!");
Scanner scanner= new Scanner(System.in);
while(true){
//1.从控制台读取用户的输入
System.out.print("-> ");
String request = scanner.next();
//2.构造出一个UDP请求,发送给服务器
DatagramPacket requestPacket= new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(this.serverIP),this.serverPort);//这里要将IP字符串转换成int类型
socket.send(requestPacket);
//3.从服务器中读取响应
DatagramPacket responsePacket= new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket);
String response= new String(responsePacket.getData(),0,requestPacket.getLength());
//4.把响应打印到控制台上
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
//127.0.0.1称为环回IP,代表本机,如果服务器和客户端在同一个主机上,就使用这个IP
UdpEchoClient udpEchoClient =new UdpEchoClient("127.0.0.1",9090);
udpEchoClient.start();
}
}
- 服务器这边创建socket时一定要指定端口号,因为客户端是通过端口号来找到服务器的,且客户端是主动发起的一方.
- 客户端这边创建socket时就最好不要指定端口号(不指定不代表没有,客户端的端口号是系统自动分配的一个端口,让系统自动分配一个端口,就能确保分配的是一个无人使用的端口).如果在客户端指定了端口号,由于客户端是在用户的电脑运行的,万一代码指定的端口和用户电脑上运行的其他程序的端口冲突,就会产生bug
服务器和客户端代码执行流程
TCP版本的socket api
TCP socket api核心类有两个
- ServerSocket 专门给服务器使用的socket对象
- Socket 给客户端使用,也可以给服务器使用
TCP是有连接的,建立连接的过程类似于"打电话",ServerSocket类里的accept相当于"接电话"("客户端打电话,服务器接电话")
下面通过编写TCP回显服务器来举例展示
TCP服务器
package net;
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){
Socket clientSocket = serverSocket.accept();//serverSocket用于帮助clientSocket建立连接
//使用多线程来实现多个客户端连接服务器
Thread t= new Thread(()->{
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
//针对一个连接,提供处理逻辑
private void processConnection(Socket clientSocket) throws IOException {
//先打印一下客户端的信息
System.out.printf("[%s:%d] 客户端上线!",clientSocket.getInetAddress(),clientSocket.getPort());
//获取到socket中持有的流对象
try(InputStream inputStream = clientSocket.getInputStream();//TCP是全双工的通信,一个socket对象既可以读也可以写
OutputStream outputStream = clientSocket.getOutputStream()){
//使用Scanner包装一下inputStream ,就可以更方便的读取这里的请求数据了
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.读取请求并解析
if(!scanner.hasNext()){
//如果scanner无法读取出数据,说明客户端关闭了连接,导致服务器读到了"末尾"
break;
}
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
//3.把响应写回客户端
//此处可以按照字节数组直接写,也可以有另一种写法
// outputStream.write(response.getBytes());
printWriter.println(response);
printWriter.flush();//刷新缓冲区
//4.打印日志
System.out.printf("[%s:%d] req=%s,resp=%s\n",clientSocket.getInetAddress(),clientSocket.getPort(),request,response);
}
}catch(IOException e){
e.printStackTrace();
}finally{//只要是方法执行完毕了,就会执行close代码
//连接失败打印日志
System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress(),clientSocket.getPort());
clientSocket.close();//客户端socket需要手动关闭
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
accept
约定换行符
TCP客户端
package net;
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 {
//这里写入ip和端口号之后意味着new好对象之后 和服务器的连接就建立完成了
//如果建立连接失败了,直接就会抛出异常
socket = new Socket(serverIp,serverPort);
}
public void start(){
System.out.println("客户端启动!");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
Scanner scannerIn = new Scanner(System.in);
PrintWriter printWriter = new PrintWriter(outputStream);
while(true){
//1.从控制台读取数据
System.out.print("->");
String request = scannerIn.next();
//2.把请求发送给服务器
printWriter.println(request);
printWriter.flush();//刷新缓冲区
//3.从服务器读取响应
if(!scanner.hasNext()){
break;
}
String response = scanner.next();
//4.打印响应结果
System.out.println(response);
}
}catch(Exception e){
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
容易产生bug的点
刷新缓冲区
PrintWrite这样的类以及很多IO流中的类都是"自带缓冲区的",引入缓冲区后,进行写数据操作不会立即触发IO,而是放到内存缓冲区中,等到缓冲区赞了一波,在进行统一发送;
所以当要发送的数据较少时 ,没办法攒够数据发送,停在了缓冲区,所以要引入PrintWrite类里的flush操作 来主动 "刷新缓冲区"
clientSocket要自动关闭close
像ServerSocket,DatagramSocket他们的生命周期都是跟随整个进程的(进程结束,会自动关闭),而服务器代码中clientSocket是"连接级别"的数据,随着客户端断开连接,这个socket就不再使用了(即使是同一个客户端,断开之后,重新连接,也和旧的socket不是同一个),这样的socket应该主动关闭以防止 文件资源泄漏.
多个客户端连接同一个服务器
此处单线程下无法处理多个客户端本质是服务器代码里 双重while循环导致的,进入了里层的while循环时,外层while无法执行了 ,所以这里要比双层while循环改成一异while,分别执行-->使用多线程解决.主线程用于accept来获取多个连接 ,每个连接都可以启动一个新的线程
虽然创建线程比创建进程 更轻量,但是也架不住短时间内 ,创建销毁大量的线程
使用线程池 可以解决短时间客户端涌入,并且每个客户端请求都很快 的问题
System.out.println("服务器启动!");
ExecutorService service = Executors.newCachedThreadPool();
while(true){
Socket clientSocket = serverSocket.accept();//serverSocket用于帮助clientSocket建立连接
//使用多线程来实现多个客户端连接服务器
// Thread t= new Thread(()->{
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
//使用线程池
service.submit(()->{
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
});
长短连接
- 长连接: 客户端连上服务器后,一个连接中会多次发出请求,接收多个响应(当前回显服务器就属于这种模式)
- 短连接: 客户端连上服务器后,一个连接只能发一个请求,接受一个响应,然后就断开连接了(可能会频繁和服务器建立/断开连接)