作者
:学Java的冬瓜
博客主页
:☀冬瓜的主页
专栏
:【JavaEE】
主要内容
:传输层协议对应Socket编程,DatagramSocket,DatagramPacket,Udp版本的客户端和服务器,UdpEchoSever,UdpEchoClient,Udp版本的查词典服务器底层原理;Tcp版本的客户端和服务器,TcpEchoServer,TcpEchoClient。Tcp版本的服务器的几个要点。
Socket API 是操作系统给应用程序提供的来进行网络数据的 发送和接收的api(即传输层给应用层使用的api)。
在需要通过操作系统来执行的传输层里,提供了两个最核心的协议:UDP和TCP。因此Socket API也提供了两种风格:UDP、TCP。下面我们来看看UDP和TCP两种方式有什么区别。
TCP:有连接,可靠传输,面向字节流,全双工。
UDP:无连接,不可靠传输,面向数据报,全双工。
直接记上面的功能可能有点懵,那我们来举例子来类比理解:
比如关于连接:打电话时,双方是先拨通后,才进行你一句我一句的通话,是有连接的;而如果是发消息,因为不需要拨通等双方都先接受,是无连接的。
比如关于是否可靠传输:打电话时,可以互相回应,可以知道我的消息对方收到没有,这是可靠传输。而如果是发消息,则我无法确定对方是否收到我的消息,则是不可靠传输。
面向的对象 :TCP是面向字节流,即操作单位是字节;而UDP是面向数据报进行编程,即操作单位是数据报(一个数据报带有一定的格式,可能有多个字节)。
全双工:比如一根水管,它只能实现单向输水,可以叫做半双工;而这里的全双工指的是一个通信管道,可以双向传输(既可以发送也可以接收),怎么实现的? 一根网线里,其实有8根线,4根负责传输,4根负责接收,这样就完美实现全双工。
基于 UDP 来编写一个客户端服务器的网络通信程序:
DatagramSocket
:使用这个类表示一个Socket对象,在操作系统中,把这个Socket对象也当成是一个文件来处理,是在文件描述符表上的一项。
区别是:普通文件对应的硬件是 硬盘;而Socket对象对应的硬件是网卡,或者说操作系统内核中,用"Socket"这样的文件对象来抽象表示网卡。
DatagramPacket
:表示UDP传输中的一个数据报。
DatagramSocket类的相关方法:
构造方法:
构造方法 | 说明 |
---|---|
DatagramSocket() | 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口一般用于客户端) |
DatagramSocket(intport) | 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端) |
普通方法:
普通方法 | 说明 |
---|---|
void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞待) |
void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
void close() | 关闭此数据报套接字 |
DatagramPacket类(数据报)的相关方法:
构造方法:
构造方法 | 说明 |
---|---|
DatagramPacket(byte[] buf, int length) | 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length) |
DatagramPacket(byte[] buf, int offset, int length,SocketAddress address) | 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从0到指定长度(第二个参数length)。address指定目的主机的IP和端口号 |
普通方法:
普通方法 | 说明 |
---|---|
InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址 |
int getPort() | 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号 |
byte[] getData() | 获取数据报中的数据(字节数组的形式) |
int getLength() | 获取数据报中数据的长度 |
SocketAddress getSocketAddress() | 获取数据报发送端主机的IP和端口号(DatagramPacket中隐含发送方的IP和端口) |
Echo Sever是一种基于客户端/服务器模型的网络应用程序,它的功能是将客户端发送的数据原封不断地返回给客户端,称为回显服务器。
这里我们主要来理解怎么实现客户端服务器,以及Socket API的使用,所以就省略了中间的业务逻辑,而是直接把客户端发来的数据直接返回。
客户端
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;
// 客户端的ip是环回ip(127.0.0.1),端口是操作系统随机分配的一个端口
// 因为在本机模拟通信,所以服务器的ip也是环回ip(127.0.0.1),端口是程序员指定的
// 服务器的ip和端口都得告诉客户端,我们才能在客户端访问服务器
private String serverIp = null;
private int serverPort = 0;
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.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.从控制台读取数据到一个空的DatagramPacket中
System.out.print("> ");
String request = scanner.next();
if(request.equals("exit")){
System.out.println("客户端关闭!");
break;
}
// 注意1:InetAddress.getByName(serverIp)操作把点分十进制的ip(127.0.0.1)转换成32位二进制数
// 注意2:发送数据报时,使用String的getBytes().length方法获取数据报长度
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length
,InetAddress.getByName(serverIp), this.serverPort);
// 2.把DatagramPacket发给服务器
socket.send(requestPacket);
// 3.使用空的DatagramPacket,接收服务器处理后的响应数据
DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
socket.receive(responsePacket); // 注意:如果receive没有接收到响应数据,那就会阻塞等待。
// 4.打印响应结果
// 注意1:打印返回的响应结果,不能用toString,因为你无法为DatagramPacket类重写toString方法
// 注意2:接收数据报时使用DatagramPacket的getLength方法获取数据报长度
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();
}
}
客户端代码步骤:
- 用一个空的DatagramPacket类型的 requestPacket接收用户从控制台输入的数据(接收字符串)
- 根据给出的服务器的ip和端口,发送这个DatagramPacket类型的 requestPacket给服务器处理数据
- 用DatagramPacket类型的空的 responsePacket接收服务器发来的处理后的数据(接收数据报)
- 把数据报类型的响应内容 requestPacket转换成字符串 request,便于打印
服务器
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class UdpEchoServer {
// 注意:1.这个socket对象在操作系统内核中操作时,是当成文件的方式操作,把这个对象当成网卡的抽象
private DatagramSocket socket = null;
// 注意:2.服务器端需要手动指定一个端口,避免客户端找不到服务器
public UdpEchoServer(int port) throws SocketException {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true){
// 1.给一个空的DatagramPacket,用于接收客户端发来的数据报
DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
socket.receive(requestPacket); // 注意:如果receive没有接收到请求数据,那就会阻塞等待。
// 注意1:为了便于处理,把DatagramPacket这个特殊的对象转化成字符串的形式,但是不能用toString,因为你无法为DatagramPacket类重写toString方法
// 注意2:接收数据报时使用DatagramPacket的getLength方法获取数据报长度
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
// 2.对请求内容进行业务处理(这里是回显服务器直接返回)
String response = process(request);
// 3.构造好响应的DatagramPacket,并把它发回客户端。
// (注意1:这里也可以直接使用requestPacket.getSocketAddress()同时获取IP和端口,客户端的端口和ip是requestPacket自带的。
// 注意2:第二个参数必须是字节数组长度response.getBytes().length,而不是字符串的长度
// 使用String的getBytes().length方法获取数据报长度)
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length
,requestPacket.getAddress(),requestPacket.getPort());
socket.send(responsePacket);
// 4.为了观察,打印一下客户端发来的的信息
System.out.printf("[%s,%d] req:%s; resp:%s\n",requestPacket.getAddress(),requestPacket.getPort()
,request, response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer sever = new UdpEchoServer(9090);
sever.start();
}
}
服务器代码步骤:
- 用一个空的DatagramPacket类型的 requestPacket接收 客户端发来的数据(接收数据报)
- 把这个requestPacket转换成字符串 request,然后进行业务处理得到字符串 response
- 用一个空的DatagramPacket类型的 requestPacket接收响应字符串 response(接收响应字符串)
然后根据requestPacket自带的客户端的ip和端口,把响应发给客户端。- 打印中间过程,客户端的ip和端口,服务器的处理请求和响应。
执行顺序:
1.服务器先启动,进行到receive进行阻塞,等待客户端发送请求数据报(服务器)
2.客户端读取用户输入内容到请求数据报(客户端)
3.客户端执行send把请求数据报发给服务器(客户端)
4.客户端发送请求数据报后立即执行到receive,等待服务器发来响应数据报(客户端)
服务器接收到请求数据报,从服务器的receive阻塞中返回(服务器)
5.服务器根据请求数据报计算响应数据报(服务器)
6.服务器执行send,发送响应数据报给客户端(服务器)
7.客户端从receive阻塞中返回,读到响应数据报(客户端)
- 注意:需要复用上面 UDP客户端+服务器 中的代码。
操作是:使用UdpDictSever继承UdpEchoSever,再重写process方法。
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDictSever extends UdpEchoServer{
// 使用一个集合来存放单词集合
private Map<String,String> dict = new HashMap<>();
public UdpDictSever(int port) throws SocketException {
super(port);
dict.put("cat","猫");
dict.put("beautiful","美丽的");
dict.put("perfect","完美的");
}
@Override
public String process(String request){
return dict.getOrDefault(request,"没有你要查的单词!");
}
public static void main(String[] args) throws IOException {
UdpDictSever sever = new UdpDictSever(9090);
sever.start();
}
}
ServerSocket
:专门给服务器使用的Socket对象。
Socket
:既可以给客户端使用,也可以给服务器使用的Socket对象。
注意1:
ServerSocket 用于服务器端本身的ServerSocket对象的创建;
Socket 用于客户端本身的Socket对象的创建(指定服务器的ip和端口) 和 服务器端accept与客户端连接后返回的Socket对象
注意2:
服务器端accept后,返回得到一个Socket对象,通过这个Socket对象和客户端 使用字节流进行 发送/接收。
ServerSocket类的相关方法:
构造方法:
构造方法 | 说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
普通方法:
普通方法 | 说明 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字ServletSocket类的对象,并绑定到指定端口 |
Socket accept() | 开始监听服务器端的绑定的端口,有客户端连接后,返回给服务器端一个Socket对象,并基于该Socket对象建立与客户端的连接,如果没有客户端连接则accept阻塞等待 |
void close() | 关闭此套接字(Socket) |
Socket类的相关方法:
构造方法:
构造方法 | 说明 |
---|---|
Socket(String host, int port) | 创建一个客户端流的Socket类的对象,和对应IP的主机上的对应端口建立连接 |
普通方法:
普通方法 | 说明 |
---|---|
InetAddress getInetAddress() | 返回调用该方法的Socket对象的对应连接的Ip |
int getPort() | 返回调用该方法的Socket对象的对应连接的端口 |
InputStream getInputStream() | 返回调用该方法的Socket对象的输入流 |
OutputStream getOutputStream() | 返回调用该方法的Socket对象的输出流 |
Tcp版本的客户端和Udp版本的客户端的区别:
Udp版本的客户端(端口和DatagramSocket建立关联) 使用两个成员变量来表示指定服务器的serverIp和serverPort,且发送数据时需要把点分十进制的目标服务器的ip(serverIp)转换成32位的二进制数据,使用数据报进行发送/接收。
Tcp版本的客户端(端口和Socket建立关联) 要先根据指定服务器的serverIp和serverPort建立连接客户端的new Socket时传入serverIp和serverPort,它可以自动识别点分十进制为32位二进制数,然后使用字节流读写网卡(即接收/发送信息),其实是在协议栈里处理,然后交由网卡发送和接收。
客户端:
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 {
// 注意1:在客户端new一个Socket对象的时候,就连接服务器。
// 注意2:Socket对象可以字节把点分十进制的serverIp转换成32位二进制数
socket = new Socket(serverIp,serverPort);
}
public void start(){
System.out.println("客户端启动!");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
Scanner scanner = new Scanner(System.in);
while (true){
// 1.客户端从控制台读取用户输入的内容
System.out.print(">");
String request = scanner.next();
if (request.equals("exit")){
System.out.println("客户端关闭!");
break;
}
// 2.客户端把请求写入网卡,发送给服务器处理
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request); //注意:要写入"\n"
printWriter.flush(); // 冲刷,保证数据写入网卡
// 3.客户端读取服务器响应写回到网卡上的数据
Scanner respScan = new Scanner(inputStream);
String response = respScan.next();
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();
}
}
客户端代码步骤:
1. 客户端从控制台读取用户输入的内容
2. 客户端把请求写入网卡,发送给服务器处理
3. 客户端读取服务器响应写回到网卡上的数据
4. 打印响应的结果
Tcp版本的服务器和Udp版本的服务器的区别:
Udp版本服务器不需要建立连接,使用数据报传输,如果客户端访问量不是很多,可以不用多线程,可以多个客户端同时访问,直接while就搞定。
Tcp版本需要建立连接,使用字节流传输,使用多线程(或者线程池),如果不用多线程,那么因为每个客户端访问都需要连接,在有客户端连接时,其它客户端则无法连接,导致无法使用,导致效率问题。
Tcp版本的服务器需要注意的点:
1> Tcp版本的服务器需要在发送消息时在数据后面加上\n
。因为接收端读取数据时使用Scanner的next方法读取,next方法规则是:读到换行符/空格/tab时结束,读到的数据不包含以上符号。所以发送端可以在数据的结尾加上\n
,表示读取数据结束。这个点客户端也是一样。如下图:printWriter.println(outputStream)
表示在发送数据outputStream后面加上一个\n
。发送outputStream后,一定记得flash,把信息真正的发送。
2> 在Tcp版本的服务器端中,需要关闭客户端访问时创建的Socket资源。每次有一个客户端访问服务器,就会创建一个Socket对象和客户端的Socket连接。服务器端每创建一个Socket对象,就在服务器的这个进程上的文件描述符表上占用一个空间,而客户端访问量应该是很多的。因此如果连接完成后,不关闭这个Socket,到了文件描述符表位置被占满时,其它客户端就无法再访问服务器了,因此,在每个客户端连接完成后,我们需要关闭服务器端的这个Socket资源,释放这个Socket占用的文件描述符表的位置。
那么为什么Udp版本的服务器不需要关闭?Udp版本服务器端的的DatagramSocket的生命周期是整个进程。而Tcp版本的clientSocket的生命周期是每个客户端连接时,断开连接,这个Socket就没用了,且因为每创建一个客户端连接,服务器就会创建一个clientSocket,所以数量上也会很多!
3> 短连接和长连接:下列代码的processConnection中的while去掉就是短连接,即传输一次就断开连接,每次访问都得先连接再发送请求;长连接即用while,当一个客户端连接好服务器然后发送请求后,先不断开连接,等待用户再次发送请求,等用户自己退出时才断开连接。
4> IO多路复用,如果客户端访问量很大,即使使用多线程服务器压力还是很大,就需要用IO多路复用。比如C10K问题(1w个客户端),C10M问题(1kw个客户端访问)。IO多路复用,可以使用一个线程处理多个客户端的任务。原理:在这个线程中使用一个集合来存放连接对象,这个线程就负责监听这个集合,在集合中哪个连接有数据来了,线程就处理这个连接。在操作系统中提供了select,epoll就可以监听。
服务器:
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 TcpEchoSever {
private ServerSocket serverSocket = null;
// 注意:服务器本身使用ServerSocket和端口绑定连接
public TcpEchoSever(int Port) throws IOException {
serverSocket = new ServerSocket(Port);
}
public void start() throws IOException {
System.out.println("启动服务器!");
// 注意:使用while保证每次有客户端连接时都能连接到
while (true){
版本一:使用多线程
// // 注意:每当有一个客户端连接服务器时,创建一个Socket对象和客户端的Socket进行通信
// Socket clientSocket = serverSocket.accept();
// // 注意:建立连接使用当前线程,放在我们创建的线程外;使用多线程去处理客户端发来的请求(处理业务)
// Thread t = new Thread(()->{
// try {
// processConnection(clientSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
// 版本二:使用线程池
Socket clientSocket = serverSocket.accept();
ExecutorService pool = Executors.newCachedThreadPool();
pool.submit(new Runnable() {
@Override
public void run() {
try {
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
// 注意:一个连接对应一个客户端,
private void processConnection(Socket clientSocket) throws IOException {
// 注意:服务器的每一个Socket对应一个客户端
System.out.printf("[%s:%d] 客户端上线!\n",
clientSocket.getInetAddress().toString(),
clientSocket.getPort());
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
// 注意:由于一个客户端可能要处理多个请求和响应,所以使用循环进行
while (true){
// 1.服务器读取客户端写入网卡的字节流数据
Scanner reqScan = new Scanner(inputStream);
if (!reqScan.hasNext()){
System.out.printf("[%s:%d] 客户端下线!\n",
clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
String request = reqScan.next();
// 注意:next读到换行符/空格/tab结束,但是读取的内容不包含换行符/空格等
// 我们这里是从客户端的请求内容就读取,所以客户端发来的请求中应当有以上结束符
// 2.对请求进行业务处理
String response = process(request);
// 3.服务器把响应内容写回网卡,响应给客户端
// 操作:用outputStream构造一个PrintWriter字符流对象,便于把"\n"一并写入网卡
PrintWriter printWriter = new PrintWriter(outputStream);
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 {
clientSocket.close();
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoSever sever = new TcpEchoSever(9090);
sever.start();
}
}
服务器代码步骤:
1. 服务器读取客户端写入网卡的字节流数据
2. 对请求进行业务处理
3. 服务器把响应内容写回网卡,响应给客户端
执行顺序:
1.服务器先启动,进行到accept进行阻塞,等待客户端new Socket从而建立连接(服务器)
2.客户端从控制台读取用户输入内容(客户端)
3.客户端使用OutputStream把请求发给服务器(客户端)
4.服务器Socket感知到请求并使用InputStream接收请求(服务器)
5.服务器根据请求计算响应(服务器)
6.服务器使用OutputStream把响应发回客户端(服务器)
7.客户端Socket感知到请求并使用InputStream接收请求(客户端)
8.客户端打印响应结果