文章目录
- 客户端和服务器
- TCP和UDP的特点
- UDP socket api的使用
- DatagramSocket
- DatagramPacket
- InetSocketAddress API
- 做一个简单的回显服务器
- UDP版本的回显服务器
- TCP版本的回显服务器
客户端和服务器
在网络中,主动发起通信的一方是客户端,被动接受的这一方叫做服务器。
- 客户端给服务器发送的数据是请求(request)
- 服务器给客户端返回的数据叫响应(response)
客户端和服务器的交互有多种模式
- 一问一答: 一个请求对应一个响应 。多在网站开发中用到
- 一问多答:一个请求对应多个相应。主要涉及“下载”的场景中
- 多问一答:多个请求队对应一个响应 。涉及到“上传”的场景中
- 多问多答:多个请求对应多个响应。用到“远程控制”和“远程桌面”中
TCP和UDP的特点
要想进行网络编程,需要使用系统的api,本质上是传输层提供的。传输层用到的协议主要是TCP和UDP。这两个协议的差别挺大,所提供的api也有一定的差别。先从本质上看看TCP和UDP的特点有哪些差别。
- TCP的特点是 有连接,可靠传输,面向字节流,全双工。
- UDP的特点是 无连接,不可靠传输,面向数据报,全双工。
有连接/无连接
举个例子,打电话,需要对方同意后才能打通电话,打电话的过程需要对方确认接听或者不接听,连接的特点就是双方都能认同,而无连接的规则向发微信,自己只管发送,不用在意对方是否同意接收。同理,计算机中的网络连接,就是通信双方,各自保存对方的信息,客户端就有一些数据结构,记录了谁是自己的服务器,服务器也有一些数据结构,记录了谁是自己的客户端。可靠传输/不可靠传输
网络上存在异常情况是非常多的,无论使用什么硬件技术都无法100%保证数据能从A发送到B。此处所说的可靠传输是指尽可能的完成数据传输,即不管数据有没有传输到,A都能清楚的知道。面向字节流/面向数据报
此处提到的字节流和文件中的字节流是一样的。网络中传输数据的基本单位就是字节。
面向数据报:每次传输的基本单位是一个数据报(有一系列字节构成的)特定的结构。全双工/半双工
一个信道可以双向通信,是全双工。只能单向通信是半双工。
UDP socket api的使用
socket api的中文意思是"网络编程套接字",操作系统中有一类文件叫socket文件,它抽象了网卡这样的硬件设备,而进行网络通信最核心的硬件设备就是网卡,通过网卡发送数据就是写socket文件,通过网卡接收数据就是读socket文件。在UDP中,核心的api有两个类,分别是DatagramSocket和DatagramPacket。下面看看这两个类的使用和注意事项。
DatagramSocket
这个类的作用主要是对soclet文件的读写,也就是借助网卡发送接收数据。接收发送接收数据的单位就是DatagramSocket.。
datagramSocket的构造方法
DatagramSocket的方法
DatagramPacket
UDP是面向数据报的,每次发送接收数据的基本单位,就是一个udp数据报,此时表示了一个UDP数据报
DatagramPacket的构造方法
DatagramPacket方法
InetSocketAddress API
做一个简单的回显服务器
UDP版本的回显服务器
回显服务器是客户端发送什么请求,服务器就返回什么响应。做这个服务器的目的是学习UDP socket
api的使用和理解网络编程中客户端和服务器的基本工作流程
服务器的基本逻辑
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();
}
}
首先创建DatagramSocket对象,接下来要使用socket对象来操作网卡。在创建对象的时候需要手动指定端口。在网络编程中,服务器一般需要手动指定端口,而客户端一般不需要。一个主机的一个端口只能被一个进程绑定,而一个进程可以绑定多个端口。
一个服务器一般都是24时运行的,直接使用while(true)可以不用退出。很多时候要重启一个服务器可以直接杀死进程。
此处 receive就从网卡能读取到一个 UDP 数据报就被放到了 requestPacket 对象中其中 UDP 数据报的载荷部分就被放到 requestPacket 内置的字节数组中了。另外报头部分,也会被 requestPacket 的其他属性保存。除了 UDP 报头之外,还有其他信息,比如收到的数据源 IP 是啥
通过 requestPacket 还能知道数据从哪里来的(源 ip 源端口)基于字节数组构造出 String字节数组里面保存的内容也不一定就是二进制数据,也可能是文本数据把文本数据交给 String 来保存,恰到好处~~
这里得到的长度是 requestPacket 中的有效长度,不一定是 40964096 是最大长度。一定是要使用有效长度来构造这里的 String使用最大长度就会生成一个非常长的 String 后半部分都是空白。通过process()方法构造响应,这是一个回显服务器,直接返回请求就可以。
通过requestPacket.getSocketAddress())获得对应客户端的ip和端口。是把请求的源ip和源端口作为响应的目的ip和目的端口。
总结
- 上述代码中,可以看到,UDP 是无连接的通信 UDP socket 自身不保存对端的IP 和端口.而是在每个数据报中有一个~.另外代码中也没有“建立连接”"接受连接”操作
- 不可靠传输,代码中体现不到的.
- 面向数据报,send和receive 都是以 DatagramPacket 为单位
- 全双工:一个 socket 既可以发送又可以接收
客户端的基本逻辑
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 使用的字符串, 点分十进制风格. "192.168.2.100"
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
//请求的目的IP和目的端口
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
while (true) {
// 要做四个事情
System.out.print("-> "); // 表示提示用户接下来要输入内容.
// 2. 从控制台读取要发送的请求数据.
if (!scanner.hasNext()) {
break;
}
String request = scanner.next();
// 3. 构造请求并发送.
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
socket.send(requestPacket);
// 4. 读取服务器的响应.
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(responsePacket);
// 5. 把响应显示到控制台上.
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}
三种DatagramSocket对象的构造方法
网络通信的基本流程
- 服务器启动.启动之后,立即进入 while 循,执行到 receive,进入阻塞,此时没有任何客户端发来请求呢。
- 客户端启动.启动之后,立即进入 while 循环,执行到 hasNext 这里进入阻塞,此时用户没有在控制台输入任何内容
- 用户在客户端的控制台中输入字符串,按下回车此时 hasNext 阻塞解除,next 会返回刚才输入的内容基于用户输入的内容,构造出一个 DatagramPacket 对象,并进行 send 。send执行完毕之后,继续执行到
reeive 操作,等待服务器返回的响应数据此时服务器还没返回响应呢,这里也会阻塞)- 服务器收到请求之后,就会从 receive 的阻塞中返回返回之后,就会根据读到的 DataqramPacket 对象,构造 String request, 通过 process 方法构造一个 String response再根据 response 构造一个
DatagramPacket表示响应对象, 再通过 send 来进行发送给客户端。执行这个过程中,客户端也始终在阻塞等待- 客户端从 receive 中返回执行.就能够得到服务器返回的响应并且打印倒控制台上于此同时,服务器进入下一次循环,也要进入到第二次的 receive 阳塞等待下个请求了
TCP版本的回显服务器
tcp中的长短连接
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
- 短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
- 长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
对比以上长短连接,两者区别如下:
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时的,长连接效率更高。
- 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求,也可以是服务端主动发。
- 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于 客户端与服务端通信频繁的场景,如聊天室,实时游戏等服务器的处理逻辑。
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;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
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 pool = Executors.newCachedThreadPool();
while (true) {
// 通过 accept 方法来 "接听电话", 然后才能进行通信
Socket clientSocket = serverSocket.accept();
// Thread t = new Thread(() -> {
// processConnection(clientSocket);
// });
// t.start();
pool.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
// 通过这个方法来处理一次连接. 连接建立的过程中就会涉及到多次的请求响应交互.
private void processConnection(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);
}
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
- 在服务器代码中,ServerSocket是创建服务端Socket的api。Socket是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket.
不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。- 当客户端发送请求的时候,内核就会发起建立连接的请求,服务器的内核会配合客户端的工作来建立连接,而内核的连接不是决定性的,还需要应用程序把这个连接接受,通过accept方法来接受连接。accept方法方法是会阻塞等待的,当没有客户端发起请求的时候此时就会阻塞。
- 上面的操作也表现出Tcp是有连接的。
- Tcp是面向字节流的。这里的字节流和文件中的字节流完全一致。使用和文件操作一样的类和方法来针对Tcp Socket的读和写。
- InputStream是往网卡上读数据,OutputStream是往网卡上写数据。
客户端的处理逻辑
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 和 port 直接传给 socket 对象.
// 由于 tcp 是有连接的. 因此 socket 里面就会保存好这俩信息.
// 因此此处 TcpEchoClient 类就不必保存.
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("客户端启动!");
try (InputStream inputStream = socket.getInputStream()) {
try (OutputStream outputStream = socket.getOutputStream()) {
Scanner scannerConsole = new Scanner(System.in);
Scanner scannerNetwork = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
while (true) {
// 这里的流程和 UDP 的客户端类似.
// 1. 从控制台读取输入的字符串
System.out.print("-> ");
if (!scannerConsole.hasNext()) {
break;
}
String request = scannerConsole.next();
// 2. 把请求发给服务器. 这里需要使用 println 来发送. 为了让发送的请求末尾带有 \n
// 这里是和服务器的 scanner.next 呼应的.
writer.println(request);
// 通过这个 flush 主动刷新缓冲区, 确保数据真的发出去了.
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 client = new TcpEchoClient("127.0.0.1", 9090);
client.start();
}
}
在TCP回显服务器中,需要注意下面几种情况
- 在上面代码中,PrintWriter中内置了缓冲区,IO操作都是很低效的,为了让低效的操作少一些,会引入一个缓冲区,先把写入网卡的数据王道缓冲区中,等达到一定的数量在一次发送出去,但如果发送的数据太少,缓冲区没有满,可能导致数据发送不出去,使用flush方法可以冲刷缓冲区,确保每条消息都能发送出去。
- ServerSocket在整个程序中,只有唯一一个对象,并且这个对象的生命周期伴随着整个程序,这个对象无法提前关闭,只有程序结束,随着进程的销毁一起结束。而clientSocket是每个客户端一个,随着客户端越来越多,如果不释放可能会占满文件描述符表。需要使用close方法关闭。
- 解决多个客户端向一个服务器发送请求的问题
上面的问题核心思路就是使用多线程,单个线程无法及给客户端提供服务,又能快速调用第二次accept,使用多线程,主线程就负责执行accept,其他线程就负责给客户端提供服务。如果客户端比较多就会频繁的创建销毁线程,就可以使用线程池解决频繁创建销毁线程的问题。