系列文章目录
【网络通信基础】网络中的常见基本概念
【网络编程】Java网络编程中的基本概念及实现UDP、TCP客户端服务器程序(万字博文)
【网络原理】UDP协议的报文结构 及 校验和字段的错误检测机制(CRC算法、MD5算法)
目录
一、什么是网络编程?
二、网络编程中的基本概念
1. 客户端和服务器
2. 请求和响应
三、Socket套接字
UDP数据报套接字编程
1. DatagramSocket
2. DatagramPacket
3. UDP回显客户端服务器程序
4. UDP字典客户端服务器程序
TCP流套接字编程
1. ServerSocket
2. Socket
3. TCP回显客户端服务器程序
4. 服务器引入多线程
5. 服务器引入线程池
6. TCP字典客户端服务器程序
一、什么是网络编程?
网络编程,指网络上的主机,通过不同的进程,以编程的方式实现网络通信(或称网络数据传输)。
即便是同一个主机,只要是不同进程,基于网络来传输数据,也属于网络编程。但是,我们的目的是提供网络上的不同主机,基于网络来传输数据资源。
网络编程,本质上就是学习“传输层”给“应用层”提供的API,通过代码的形式,把数据交给传输层,进一步的通过层层封装,就可以把数据通过网卡发送出去了。
二、网络编程中的基本概念
1. 客户端和服务器
客户端:主动发起通信的一方,称为客户端.
服务器:被动接受的一方,称为服务器,可以提供对外服务.
同一个程序在不同场景中,可能是客户端也可能是服务器(服务器可能还需要主动向别的服务器发起通信,此时的服务器相对于被发起通信的服务器来说,就是客户端).
2. 请求和响应
请求(request):客户端给服务器发送数据。
响应(response):服务器给客户端返回数据。
一般来说,获取一个网络资源,涉及到两次网络数据传输:
- 第一次:请求数据的发送.
- 第二次:响应数据的发送.
就比如在快餐店点一份炒饭:
先要发起请求:点一份炒饭;再有快餐店提供的对于响应:提供一份炒饭。
三、Socket套接字
前面说过,要想进行网络编程,需要使用的系统API,本质上是由传输层提供的。
传输层涉及到的主要协议有两个:
- 流套接字:TCP(传输控制协议)
- 数据报套接字:UDP(用户数据报协议)
TCP的特点:
- 有连接(类似于打电话,需要接通才能通信)
- 可靠传输(尽可能完成数据传输,起码可以知道当前这个数据对方是否接收到了)
- 面向字节流(此处字节流和文件中的字节流完全一致,网络中传输数据的基本单位就是字节)
- 全双工(一个信道,可以双向通信)
UDP的特点:
- 无连接(类似于发微信/短信,直接发送过去)
- 不可靠传输(没有确认、重传机制;如果因为网络故障等原因,数据无法发到对方,UDP协议层也不会给应用层返回任何错误信息)
- 面向数据报(每次传输的基本单位是一个数据报(由一系列的字节构成的)特定的结构)
- 全双工(一个信道,可以双向通信)
UDP数据报套接字编程
UDP socket API的使用
1. DatagramSocket
DatagramSocket 是 UDP Socket(套接字),用于发送和接收UDP数据报
构造方法:
重要方法:
2. DatagramPacket
DatagramPacket 是 UDP Socket(套接字)发送和接收的数据报(每次发送接收数据的基本单位)
构造方法:
3. UDP回显客户端服务器程序
通过这个程序,了解 socket api 的使用,和典型的客户端服务器基本工作流程。
对于服务器,需要指定端口号来创建 socket (类似于饭店,需要指定具体位置),主要流程如下:
- 接收客户端请求,并解析
- 根据请求,计算出响应(回显服务器,则是直接将请求的数据返回)
- 将响应写回给客户端
注意事项详见代码注释:
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;//Udp回显服务器
public class UdpEchoServer {//Udp套接字private DatagramSocket socket;public UdpEchoServer(int port) throws SocketException {socket = new DatagramSocket(port); //服务器:指定端口号创建}public void start() throws IOException {System.out.println("服务器启动");while (true) {//1.接收客户端的请求,并解析DatagramPacket requestServer = new DatagramPacket(new byte[4096], 4096);socket.receive(requestServer);//2.根据请求,计算出响应String request = new String(requestServer.getData(), 0, requestServer.getLength());String response = process(request);//3.将响应写回给客户端(需要指定发送到的IP地址及端口号)DatagramPacket responseServer = new DatagramPacket(response.getBytes(), response.getBytes().length, requestServer.getSocketAddress());socket.send(responseServer);//打印日志System.out.printf("[%s:%d] request:%s response:%s\n",responseServer.getAddress(), responseServer.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();}
}
对于客户端,服务器的端口号可以由系统随机分配,但需要知道服务器的IP地址及端口号(去饭店吃饭,需要知道饭店的地址及具体是哪个门店),主要流程如下:
- 客户端读取用户请求
- 构造请求的数据报,并发送到服务器(此时就需要指定服务器的IP地址及端口号)
- 读取服务器的响应,并解析出响应的内容
- 输出服务器的响应
import java.io.IOException;
import java.net.*;
import java.util.Scanner;//Udp回显客户端
public class UdpEchoClient {private DatagramSocket socket;private String address;private int port;//客户端需要知道服务器的IP地址及端口号public UdpEchoClient(String address, int port) throws SocketException {this.address = address;this.port = port;socket = new DatagramSocket(); //服务器:随机端口号创建}public void start() throws IOException {System.out.println("客户端启动");Scanner in = new Scanner(System.in);while (true) {System.out.print("-> ");if (!in.hasNext()) {break;}//1.控制台读取请求内容String request = in.next();//2.构造请求的数据报,并发送到服务器(需要指定目的IP地址和目的端口号发送请求)DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length, InetAddress.getByName(address), port);socket.send(requestPacket);//3.读取服务器的响应,并解析出响应的内容DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);socket.receive(responsePacket);String response = new String(responsePacket.getData(), 0, responsePacket.getLength());//4.将响应内容输出到控制台System.out.println(response);}}public static void main(String[] args) throws IOException {UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);udpEchoClient.start();}
}
先运行服务器,再运行客户端,看程序的执行效果:
- 可以看到,服务器端能够正确接收到请求并作出响应,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
这个程序并不能直接做到“跨主机通信”,因为这台主机可能不能直接访问到另一台主机(NAT机制)。但是可以通过以下手段实现“跨主机通信”:
- 将服务器端程序打成 jar 包
- 把 jar 包传到云服务器上,并运行
经过这样的操作,其他主机通过运行上述的客户端程序,就能够发起通信了。
4. UDP字典客户端服务器程序
基于上述回显服务器,还可以实现出一些其他带有一点业务逻辑的服务器。
改进成一个“字典服务器”,英译汉的效果。请求是一个英文单词,响应返回对应的中文翻译。
主要逻辑其实和回显服务器基本一致,唯一的区别就在于,服务器端将客户端请求的数据,计算成响应的方式不一致。回显服务器是直接返回客户端请求的数据,这里的字典服务器则是英译汉效果。
而上述代码中,这个根据请求数据计算响应数据的操作,是通过process方法实现的。因此只需要让这个字典服务器继承回显服务器,并重写process方法即可。这里英译汉的业务逻辑通过打表的方式实现。
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;//Udp字典服务器
public class UdpDictServer extends UdpEchoServer {Map<String, String> map;public UdpDictServer(int port) throws SocketException {super(port);map = new HashMap<>();map.put("cat", "小猫");map.put("dog", "小狗");map.put("animal", "动物");}//通过重写 计算响应的process方法,达成 英->汉 的效果@Overridepublic String process(String request) {return map.getOrDefault(request, "找不到该单词");}public static void main(String[] args) throws IOException {UdpDictServer udpDictServer = new UdpDictServer(9090);udpDictServer.start();}
}
还是先运行字典服务器,再运行回显客户端(这里客户端是通用的,因为回显客户端只进行发送请求和接收响应并解析的操作),看程序的执行效果:
- 同样,服务器端能够正确接收到请求、解析请求,并计算出响应、写回给客户端,并打印出日志(客户端IP,客户端端口号,请求内容,响应内容)
- 客户端也能够正确发送请求,并正确解析服务器端返回的响应
TCP流套接字编程
1. ServerSocket
ServerSocket 类是创建TCP服务器端Socket的API. (只能给服务器端使用)
构造方法:
重要方法:
2. Socket
Socket 类用于创建客户端 Socket,或服务器端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket. (服务器端和客户端都能使用)
构造方法:
重要方法:
3. TCP回显客户端服务器程序
使用TCP协议实现回显客户端服务器程序。与UDP协议实现的最大区别是,TCP是有连接的,和打电话一样,需要一方(客户端)拨号,一方(服务器)接通,因此TCP协议首要操作就是等待客户端连接。
和UDP回显服务器一样,对于这里的服务器,同样需要指定端口号创建TCP服务器端Socket,即ServerSocket。
- 服务器启动后,就需要监听当前绑定端口(accept方法),等待客户端连接。
- 当成功建立连接后,会返回一个Socket对象。这个对象保存了对端信息,即客户端信息,可以用来接收和发送数据(TCP是面向字节流的,通过这个Socket对象获取对应输入流和输出流,通过输入输出流进行对 socket 的读写,达成接收和发送数据的功能)。
后续流程和UCP回显服务器一致。此处由于每有一个客户端连接,就会有一个clientSocket,这里消耗的Socket会越来越多,因此每当一个客户端连接结束,就需要释放这个clientSocket。
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;//TCP回显服务器
public class TcpEchoServer {private ServerSocket serverSocket;public TcpEchoServer(int port) throws IOException {//指定服务器端口号,创建一个serverSocketserverSocket = new ServerSocket(port);}public void start() throws IOException {System.out.println("服务器启动!");while (true) {//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息Socket clientSocket = serverSocket.accept();processConnection(clientSocket);}}private void processConnection(Socket clientSocket) throws IOException {System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());try (InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {while (true) {//1.读取客户端请求的数据//利用scanner读取客户端输入的信息Scanner scanner = new Scanner(inputStream);if (!scanner.hasNext()) {System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress(), clientSocket.getPort());break;}//这里的next()需要遇到\n才停止,因此需要对端写入的时候,要同时写入\n换行符String request = scanner.next();//2.解析请求的数据,并计算出响应String response = process(request);//3.将响应写回到客户端//outputStream.write(response.getBytes(), 0, response.getBytes().length);PrintWriter writer = new PrintWriter(outputStream);writer.println(response);writer.flush();//打印日志System.out.printf("[%s:%d] request:%s response:%s\n",clientSocket.getInetAddress(), clientSocket.getPort(), request, response);}} catch (IOException e) {throw new RuntimeException(e);} finally {clientSocket.close();}}//回显服务器,直接返回原数据public String process(String request) {return request;}public static void main(String[] args) throws IOException {TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);tcpEchoServer.start();}
}
对于客户端,需要指定服务器的IP和端口号建立连接,即使用 Socket(String host, int port) 创建Socket的时候,就开始发起与对应服务器建立连接的请求了。
主要流程和UDP回显客户端程序的流程也基本一致,只需要注意请求和响应数据的方式是不同的,是通过操作输入输出流完成的即可。
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.InetAddress;
import java.net.Socket;
import java.util.Scanner;//TCP回显客户端
public class TcpEchoClient {private Socket clientSocket;//需要指定服务器的IP和端口号public TcpEchoClient(String serverAddress, int serverPort) throws IOException {//与对应客户端建立连接clientSocket = new Socket(InetAddress.getByName(serverAddress), serverPort);}public void start() {System.out.println("客户端启动!");try (Scanner scannerConsole = new Scanner(System.in);InputStream inputStream = clientSocket.getInputStream();OutputStream outputStream = clientSocket.getOutputStream()) {while (true) {//1.用户从控制台输入数据System.out.print("-> ");String request = scannerConsole.next();//2.将该数据作为请求,发送给服务器//outputStream.write(request.getBytes(), 0, request.getBytes().length);//outputStream.write('\n');PrintWriter writer = new PrintWriter(outputStream);writer.println(request);writer.flush(); //刷新缓冲区,确保数据发送出去//3.读取服务器的响应,并解析响应的内容Scanner scannerNetwork = new Scanner(inputStream);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方法中.
- 接下来服务器就会在processConnection循环处理客户端的请求,只有当客户端退出之后,连接结束,才会退出循环.
- 而服务器在循环处理客户端请求的时候,第二个客户端发起连接请求,而服务器这里并不能执行到accept。因此并不能成功连接,只有当客户端退出,才会执行回到accept进行连接.
第二个客户端之前发的请求为什么能被立即处理?
- 当前TCP在内核中,每个 socket 都是由缓冲区的。客户端发送的数据通过客户端代码,已经写入到该客户端的输出流(缓冲区)了,这里数据确实发送了,只不过数据在服务器的接收缓冲区中。
- 一旦第一个客户端退出,回到第一层循环,执行accept连接操作,后续processConnection方法里的 next 就能把之前缓冲区的内容给读出来。
解决上述问题的核心思路就是使用多线程:
- 单个线程,无法既能给客户端提供服务,又能去快速执行到第二次 accept 方法进行连接。
- 通过引入多线程,让主线程只负责执行 accept。每次有一个客户端连接上来,就分配一个新的线程,由新的线程负责给客户端提供服务。
由于这里不涉及多个线程修改共享变量,因此没有线程安全问题,我们只需要改动 start 方法即可。
4. 服务器引入多线程
//多线程public void start() throws IOException {System.out.println("服务器启动!");while (true) {//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息Socket clientSocket = serverSocket.accept();Thread t = new Thread(() -> {try {processConnection(clientSocket);} catch (IOException e) {throw new RuntimeException(e);}});t.start();}}
通过引入多线程,这里的服务器就能支持多个客户端同时与其通信了。
上述问题,不是TCP引起的,而是代码两次循环嵌套引起的,UDP服务器,就是只有一层循环,因此不会有这个问题。
而这个多线程版本同样还有一些问题:
- 每有一个客户端连接,就会创建一个新的线程,每当这个客户端结束,就要销毁这个线程。
- 如果客户端比较多,并且频繁连接、关闭,就会使服务器频繁创建和销毁线程。
前面讲过,线程池解决的就是线程频繁创建和销毁的问题,因此,这里的优化方案就是使用线程池。
5. 服务器引入线程池
public void start() throws IOException {System.out.println("服务器启动!");ExecutorService threadPool = Executors.newCachedThreadPool();while (true) {//监听当前绑定的端口,等待客户端连接 连接后,返回一个socket,里面保存客户端(对端)信息Socket clientSocket = serverSocket.accept();threadPool.submit(new Runnable() {@Overridepublic void run() {try {processConnection(clientSocket);} catch (IOException e) {throw new RuntimeException(e);}}});}}
线程开销问题解决了。但是,如果当前的场景使线程频繁创建,但是不销毁呢?
- 这种情况下,如果继续使用多线程/线程池,就会导致当前服务器积累大量的线程,此时,对于服务器的负担是非常重的。
要解决这个新问题,还可以引入其他的方案:
- 协程:轻量级线程,本质上还是一个线程,用户态可以通过手动调度的方式让这一个线程“并发”执行多个任务。
- IO 多路复用:系统内核级别的机制,本质上是让一个线程同时去负责处理多个socket。
6. TCP字典客户端服务器程序
同UDP字典客户端服务器程序:
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;//TCP字典服务器
public class TcpDictServer extends TcpEchoServer {Map<String, String> map;public TcpDictServer(int port) throws IOException {super(port);map = new HashMap<>();map.put("cat", "小猫");map.put("dog", "小狗");map.put("animal", "动物");}@Overridepublic String process(String request) {return map.getOrDefault(request, "未找到该单词");}public static void main(String[] args) throws IOException {TcpDictServer tcpDictServer = new TcpDictServer(9090);tcpDictServer.start();}
}