在网络中,主动发起请求的一方就是客户端,被动接受的一方就是服务器。
客户端发送给服务器的数据,叫做请求(request)。
服务器返回给客户端的数据,叫做响应(response)。
客户端-服务器之间的交互:
一问一答<----->场景:web开发
一问多答<----->场景:下载
多问一答<----->场景:上传
多问多答<----->场景:远程控制
进行网络通信,需要调用系统的api,本质上是传输层提供的传输层,涉及到的协议主要是TCP和UDP。
区别:
TCP | UDP |
---|---|
有连接 | 无连接 |
可靠传输 | 不可靠传输 |
面向字节流 | 面向数据报 |
全双工 | 全双工 |
连接:网络上的连接是抽象的,本质上就是通信双方保存了对方的相关信息。
有连接类似于打电话,需要对方接通
无连接类似于发短信,无需对方接通
可靠传输:这里的可靠传输就是发的数据到没到,发送方能够清楚的感知到。
面向字节流:网络中传输的数据的基本单位是字节。
面向数据报:每次传输的基本单位是数据报。
全双工:一个信道可以双向通信,就像公路一样是双向车道。
半双工:只能单向通信,就像过独木桥。
socket 是操作系统给应用程序(传输层给应用层)提供的API,Java对这个API进行了封装。
socket提供了两组不同的 API,UDP有一套,TCP有一套,本文主要介绍api的使用
Java把系统原生的API进行了封装,操作系统中有一类文件叫做 scoket 文件,抽象的表示了"网卡"这样的设备,通过操作scoket文件就可以对网卡进行操作。
通过网卡发送数据,就是写scoket文件
通过网卡接收数据,就是读socket文件
DatagramScoket是UDP scoket,用于接收和发送数据报
构造方法 | 说明 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端) |
DatagramSocket(int port) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(port就是端口号) |
内置方法 | 说明(下面的DatagramPacket p是作为输出型参数的) |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭数据报套接字 |
输出型参数:输出型参数是一个变量,函数会修改它的值,并将修改后的值传递回调用者。调用者可以通过这个参数获取函数处理后的数据。就像是我们自己带饭盒去食堂吃饭,饭盒就相当于DatagramPacket,打饭的阿姨会帮我们把饭盒装满饭菜,此时打饭的阿姨就是void receive。
UDP面向数据报,每次发送接收数据的基本单位,就是一个UDP数据报。
构造方法 | 说明 |
---|---|
DatagramPacket(byte[] buf, int length) | 构造一个 DatagramPacket 用来接收数据报,接收的数据保存在字节数组 buf 中,接收指定的长度 length |
DatagramPacket(byte[] buf,int offset,int length,SocketAddress address) | 构造一个 DatagramPacket 用来接收数据报,接收的数据保存在字节数组 buf 中,接收指定的长度 length,address 表示指定的目的主机的 ip 和端口号 |
内置方法 | 说明(下面的DatagramPacket p是作为输出型参数的) |
---|---|
InetAddress getAddress() | 从接收的数据报中,获取发送端主机 IP 地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或者从发送的数据报中,获取接收端主机的端口号 |
byte[] getData() | 获取数据报中的数据 |
回显服务器(Echo Server)是一种网络服务器,其主要功能是将接收到的数据原样返回给发送者。
package network;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
private DatagramSocket socket = null;
public UdpEchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
//服务器启动逻辑
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
//每次循环,就是一个请求-响应过程
//1.读取请求并解析
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
//读到的字节数组,转成String方便后续的逻辑处理
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
//2.根据请求计算相应(对于辉县服务器来说,这一步啥都不用做)
String response = process(request);
//3.把响应返回给客户端
//构造一个DatagramPacket作为响应对象
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length, 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 server = new UdpEchoServer(9090);
server.start();
}
}
package network;
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 {
this.serverIp = serverIp;
this.serverPort = serverPort;//这里是十进制位的IP地址
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while (true) {
//1.从控制台上读取要发送的数据
System.out.print("->");//输入
if (!scanner.hasNext()){
break;
}
String request = scanner.next();
//2.构造请求并发送
DatagramPacket requestPacker = new DatagramPacket(request.getBytes(),request.getBytes().length,
InetAddress.getByName(serverIp),serverPort);//这里是改为二进制后的IP地址
socket.send(requestPacker);
//3.读取服务器的响应
DatagramPacket responsePacker = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacker);
//4.把响应显示在控制台
String response = new String(responsePacker.getData(),0,responsePacker.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
udpEchoClient.start();
}
}
服务器启动,启动之后,立刻进入while循环,执行到receive,进入阻塞。此时没有任何客户端发来请求。
客户端启动,启动之后,立刻进入while循环,执行到hasNext,进入阻塞。此时用户没有在控制台输入任何内容。
用户在客户端的控制台中输入字符串,按下回车,此时hasNext阻塞解除,next会返回刚才输入的内容。
基于用户输入的内容,构造出一个DatagramPacket对象,并进行send。
send执行完毕之后,执行到receive操作,等待服务器返回的响应数据。
服务器收到请求之后,就会从receive的阻塞中返回。
返回之后,就会根据读到的DatagramPacket对象,构造String request,通过process方法构造一个String response。
再根据response构造一个DatagramPacket表示响应对象,在通过send来进行发送给客户端。
执行这个过程中,客户端始终在阻塞等待。
客户端从receive中进行返回,就能够得到服务器返回的响应,并且打印在控制台上。
与此同时,服务器也进入下一次循环,也要进入到第二次的receive阻塞,等待下一个请求。
package network;
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
public class UdpDictServer extends UdpEchoServer{
private HashMap<String,String> hashMap = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
hashMap.put("cat","小猫");
hashMap.put("dog","小狗");
hashMap.put("chicken","小鸡");
}
//start() 方法完全从父类集成下来即可
//process() 方法要进行重写,加入咱们自己的业务逻辑,进行翻译
@Override
public String process(String request) {
return hashMap.getOrDefault(request,"您查的单词不存在");
}
public static void main(String[] args) throws IOException {
UdpDictServer udpDictServer = new UdpDictServer(9090);
udpDictServer.start();
}
}
TCP是面向字节流的,传输的基本单位是字节,TCP协议是需要建立连接的。
连接建立:从客户端Socket的构造方法发送连接请求,服务器的SerevrSocket监听到请求后并且调用accept()方法,这样就建立了连接,然后accept()方法在服务器中会生成一个新的Socket对象用来进行通信。
构造方法 | 说明 |
---|---|
ServerSocket(int port) | 创建⼀个服务端流套接字Socket,并绑定到指定端⼝ |
Socket(String host, int port) | 创建⼀个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接 |
ServerSocket 类的内置方法:
内置方法 | 说明 |
---|---|
Socket accept() | 开始监听指定端⼝(创建时绑定的端⼝),有客户端连接后,返回一个Socket对象,并且基于Socket建立与客户端的连接,没有就阻塞等待 |
void close() | 关闭套接字 |
Socket 类的内置方法:
内置方法 | 说明 |
---|---|
InetAddress getInetAddress() | 返回套接字锁连接的地址 |
InputStream getInputStream() | 返回此套接字的输⼊流 |
InputStream getOutputStream() | 返回此套接字的输出流 |
ServerSocket只能在服务器中使用,而Socket既可以在服务器中使用也可以在客户端使用。
TCP是有连接的,就类似需要客户端拨打电话,服务器来接听。
过程就像是客户端的 Socket 想要通过服务器的 ServerSocket 认识服务器中的 Socket。于是客户端的 Socket 就请求服务器的 ServerSocket 帮忙牵线搭桥,服务器的 ServerSocket 就把服务器的 Socket 的电话号码给了客户端,而客户端的构造方法就类似于给服务器拨通了电话,而当前只是在响铃,而accept()方法就类似接听,只有调用accept()的方法后才算真正建立连接。
InputStream inputStream=clientSocket.getInputStream();
OutputStream outputStream=clientSocket.getOutputStream();
从网卡内读数据以及往网卡内写数据,TCP中操作socket文件,对其进行读写(InputStream,OutputStream),就是在操作网卡,操作系统把网卡抽象成了一个文件。
package network;
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 TcpEchoSever {
private ServerSocket serverSocket = null;
public TcpEchoSever(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//通过accept方法来“接听电话”,然后才能通信.如果没有客户端连过来,会进入阻塞状态
Socket clientSocket = serverSocket.accept();
processConnention(clientSocket);
}
}
//通过这个方法来处理一次连接,连接建立的过程中涉及到多次的请求响应交互
private void processConnention(Socket clientSocket) {
System.out.printf("[%s: %d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());
//循环读取客户端的请求并返回响应
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream();) {
while (true) {
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
//读取完毕,客户端断开连接,
System.out.printf("[%s: %d] 客户端下线\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
//1.读取请求并解析,这里注意隐藏约定,next 读到空白符(\n 或者 空格)才结束
String request = scanner.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, resp: %s\n", clientSocket.getInetAddress(), clientSocket.getPort(), request, response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}finally {
try {
clientSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoSever tcpEchoSever = new TcpEchoSever(9090);
tcpEchoSever.start();
}
}
Scanner scannerConsole = new Scanner(System.in);
Scanner scannerNetwork = new Scanner(inputStream);
在客户端上,scannerConsle 是在控制台中读取数据,也就是我们用户输入的时候读取数据,并且转变为 String 发送给服务器(就是通过OutputStream写入操作网卡的文件)。scannerNetwork 就是在服务器做出响应后,通过 inputStream 读取网卡上的数据 最终打印出结果。
PrintWriter writer = new PrintWriter(outputStream);
//2.把请求发送给服务器,这里使用println来发送,是为了让末尾带有\n,与服务器的scanner.next呼应
writer.println(request);
writer.flush();//通过主动刷新缓冲区,确保数据真正发送出去
PrintWriter 是 Java 中的一个类,位于 java.io 包中,用于以文本形式写入输出数据。它继承了 Writer 抽象类,提供了多种方法来方便地写入字符和字符串到文件或其他输出流中。
flush() 方法的作用是刷新缓冲区。因为IO都是比较低效的操作,一次一次读写,太麻烦。缓冲区就将先把数据放到内存缓冲区中,等攒够了数据一起发送,这样就变得高效了,而flush() 就是将缓存区刷新,将数据一点一点发送出去,不用等到满了一股脑发出去。
package network;
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;
public TcpEchoClient(String severIp, int severPort) throws IOException {
//此处直接将ip和port传给socket对象。由于TCP是有连接的,因此socket里面会保存好这两信息,故TcpEchoClient就不用保存
socket = new Socket(severIp,severPort);
}
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 writer = new PrintWriter(outputStream);
while(true){
//1.从控制台读取输入的字符串
System.out.print("->");
if (!scannerConsole.hasNext()){
break;
}
String request = scannerConsole.next();
//2.把请求发送给服务器,这里使用println来发送,是为了让末尾带有\n,与服务器的scanner.next呼应
writer.println(request);
writer.flush();//通过主动刷新缓冲区,确保数据真正发送出去
//3.从服务器读取响应,与服务器返回响应的逻辑相呼应
String response = scannerNetwork.next();
//4.把响应显示出来
System.out.println(response);
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
上述代码问题
第一个客户端连上服务器后,服务器就会从accept()阻塞返回,进入processConnection()中;接下来会在scanner.hasNext()阻塞,等待客户端的返回;客户端请求到达之后,从scanner.hasNext()阻塞返回,读取请求;根据请求计算响应,返回响应给客户端。执行完上述操作后,循环回来继续在scanner.hasNext()阻塞,等待下一个请求,直到客户端退出,连接退出,此时循环才会结束。此时,第一个客户端不断开连接,服务器代码无法执行到accept()。
解决方案
使用多线程,主线程负责执行accept() 。每次有客户端连上来,就分配一个新线程,由新线程负责给客户端提供服务。
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
//通过accept方法来“接听电话”,然后才能通信.如果没有客户端连过来,会进入阻塞状态
Socket clientSocket = serverSocket.accept();
//通过将processConnection操作交给新线程,主循环会快速的返回到accept阻塞,等待下一个客户端的到来
Thread thread = new Thread(() -> {
processConnention(clientSocket);
});
thread.start();
}
}
上述问题,不是 TCP 引起的,而是之前代码没写好,两层循环嵌套引起的。 UDP 服务器只有一层循环,就不涉及到这样的问题。 UDP 服务器天然就可以处理多个客户端的请求。
每次来一个客户端,就会创建一个新的线程,每次这个客户端结束,就要销毁这个线程,如果客户端比较多,就会使服务器频繁创建销毁线程。
线程池,解决的是频繁创建销毁的问题。如果使用线程池/多线程,此时就会导致当前的服务器上一下积累了大量的线程,此时对于服务器的负担会非常重。
协程,轻量级线程,本质上还是一个线程,用户态可以通过手动调度的方式让这一个线程"并发"的做多个任务。
IO多路复用,系统内核级别的机制,本质上让一个线程同时负责执行多个 socket ,在于让这些 socket 并非是同一时刻都需要处理。基本盘在于,虽然有多个 socket ,但是同一时刻活跃的 socket 只是少数。
单线程:
一个人去买
1)先去买熏肉大饼,等好了之后,再去第二个小摊
2)再买肉夹馍,等好了之后再去,第三个小摊
3)再去买饺子,等好了之后,齐活了。
多线程:
三个人一起出动
1)A负责去买熏肉大饼
2)B负责去买肉夹馍
3)C负责去买饺子
I0多路复用:
一个人去买
1)先买熏肉大饼,点完单,付完钱,不等了,直接去下一个小摊
2)再买肉夹馍,也不等了,直接下一个小摊
3)再去买饺子。
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
//通过accept方法来“接听电话”,然后才能通信.如果没有客户端连过来,会进入阻塞状态
Socket clientSocket = serverSocket.accept();
//使用线程池,来解决问题
service.submit(new Runnable() {
@Override
public void run() {
processConnention(clientSocket);
}
});
}
}