目录
前言
本文介绍了网络编程的基础概念和实现方法。主要内容包括:1.网络基础概念,如局域网/广域网、五元组、协议分层及封装分用过程;2.UDP编程,详细说明了UDP的特性、核心API,并实现了回显服务器和词典服务器;3.TCP编程,介绍了TCP的特点、核心API,实现了回显服务器和客户端,讨论了多线程和线程池优化方案。文章通过具体代码示例,展示了如何使用Java进行UDP和TCP网络编程,并比较了两种协议的特点和适用场景。
一、网络的基础概念
1. 局域网和广域网
网络分为局域网(LAN)和广域网(WAN);
组建局域网和广域网需要用到路由器或者交换机;
交换机上的口都是等价的,所有接到交换机上的网络设备就组成了一个局域网;
路由器上的口分为两种,一种 LAN 口,用于接网络设备;,一种 WAN 口,用于接运营商的网络;
区别:交换机工作在数据链路层,路由器工作在网络层;
2. 五元组
IP 地址:
描述了一个设备在网络上的位置;
本质上是一个四字节的整数,往往通过点分十进制的方式进行表示,例如:192.168.0.100;
端口号:
描述了主机上的某个应用程序;
主机上有很多应用程序,端口号用于区分哪个应用程序;
每个应用程序在网络通信时,都需要有一个端口号,端口号可以是手动指定的,也可以是系统自动分配的;
一次网络通信的过程中(5元组):
涉及两个 IP 地址和两个端口号:
- 源 IP:描述了信息的发送方,是网络上的哪台主机;
- 源端口号:描述了信息的具体发送方是哪个进程;
- 目的 IP:描述了信息的接收方,是网络上的哪台主机;
- 目的端口号:描述了信息的具体接收方是哪个进程;
- 协议:标识发送进程和接收进程双方约定好的数据格式;
3. 协议分层
TCP/IP 五层网络协议:
- 应用层:进程拿到数据后,用于解决实际问题;
- 传输层:关注网络数据包的起点和终点,也称为端到端之间的传输;
- 网络层:关注起点和终点之间的路径规划;
- 数据链路层:负责相邻两个节点间的传输;
- 物理层:通信基础设施,纯硬件设施;
对于一台主机,操作系统内核实现了传输层到物理层的内容,也就是 TCP/IP 五层模型的下四层;
对于一台路由器,它实现了网络层到物理层,也就是 TCP/IP 的下三层;
对于一台交换机,它实现了数据链路层到物理层,也就是 TCP/IP 五层模型的下两层;
协议分层的意义:
- 庞大复杂的协议不利于学习和维护,因此拆分成多个功能单一的协议,更有利于学习和维护;
- 上层协议直接调用下层协议即可,不必关注下层协议的实现细节;
- 某一层的协议发生替换后,对其他层不产生影响;
4. 封装和分用
1. 封装的过程
1. 在应用层,应用程序按照应用层的协议将数据包构造好;
2. 数据包构造好,应用程序调用操作系统提供的 API,把这个数据包交给传输层;
3. 传输层会把上述数据包作为一个整体(载荷),凭接上传输层的报头,构造成一个新的数据包;
4. 传输层把数据包构造好后,将数据包交给网络层;
5. 网络层会把传输层的数据包作为一个整体(载荷),凭借上网络层的报头,构造成一个新的数据包;
6. 数据包构造好后,会把数据包交给数据链路层;
7. 数据链路层会把网络层的数据包作为一个整体(载荷),前面拼接上以太网的帧头,后面拼接上以太网的帧尾,构造成一个新的数据包;
8. 数据包构造好后,将数据包交给物理层;
9. 物理层将数据包转化为光信号或电信号,在网络上进行传输;
2. 分用的过程
1. 物理层接收到光信号或者电信号后,将物理信号转化为数字信号,交给数据链路层;
2. 数据链路层接收到数据包后,按照以太网帧格式进行解析,取出其中载荷,将载荷交给网络层;
3. 网络层接收到数据包后,按照网络层协议进行解析,取出其中载荷,将载荷交给传输层;
4. 传输层接收到数据包后,按照传输层协议进行解析,取出其中载荷,将载荷交给应用层;
5. 应用程序拿到数据包后,按照应用层的协议进行解析,拿到发送方发送的数据;
3. 数据包在网络上的传输
数据包封装后,往往不是立马传到接收方的网卡上,而是需要在网络中经过多个节点,最终到达接收方;
网络中的节点有交换机,也有路由器;
经过交换机:
1. 经过交换机的时候,数据包就会在交换机分用,交换机一般分用到数据链路层,即按照以太网协议解析,拿到载荷;
2. 拿到载荷后,再根据以太网帧头中的信息,将数据包重新封装,即加上以太网的帧头和帧尾,交给物理层;
3. 物理层将数字信号转化为光信号或者电信号,在网络上继续传输;
经过路由器:
1. 经过路由器的时候,数据包则会在路由器分用,路由器通常工作在网络层,即先按照以太网协议解析,拿到载荷,再交给网络层;
2. 网络层拿到数据后,按照网络层协议解析,拿到载荷;
3. 拿到载荷后,再根据原来网络层报头中的信息,重新将数据包封装,即拼接上网络层的报头,交给数据链路层;
4. 数据链路层拿到数据包后,根据原本数据链路层以太网帧头中的信息,再将数据包作为整体重新封装,即拼接上以太网的帧头和帧尾,交给物理层;
5. 物理层将数字信号转化为光信号或者电信号,在网络上继续传输;
二、网络编程
1. 重要概念
客户端:主动发起请求的一方;
服务器:被动接收,返回响应的一方;
客户端发给服务器的数据,称为请求(request);
服务器返回给客户端的数据,称为响应(response);
客户端和服务器交互有多种模式:
1. 一问一答:一个请求对应一个响应,在网站开发中比较常见;
2. 一问多答:一个请求对应多个响应,在下载的场景中比较常见;
3. 多问一答:多个请求对应一个响应,在上传的场景中比较常见;
4. 多问多答:多个请求对应多个响应,在远程时比较常见;
2. UDP 编程
网络编程需要使用系统的 API,本质上是传输层提供的;
传输层涉及两个协议,一个是 UDP,一个是TCP;
TCP 协议的特点:有连接、可靠传输、面向字节流、全双工;
UDP 协议的特点:无连接,不可靠传输、面向数据报,全双工;
操作系统有一类特殊的文件,即 socket 文件,针对 socket 文件读写,就是借助网卡发送和接收数据;
1. UDP 的核心 API
UDP 协议有两个核心的 API,分别是 DatagramSocket 和 DatagramPacket;
DatagramSocket():创建一个 UDP 数据报的 socket,绑定到本机的任意端口,通常应用于客户端;
DatagramSocket(int port):创建一个 UDP 数据报的 socket,绑定到本机指定的端口,通常应用于服务器;
服务器必须要手动指定端口号,因为客户端访问服务器需要提前知道服务器的端口,如果服务器随机指定端口号,客户端不知道服务器的端口号就无法访问;
void receive(DatagramPacket p):从 socket 接收数据报,如果没有数据报,该方法会阻塞等待;
void send(DatagramPacket p):从 socket 发送数据报;
通过网卡接收数据,就是读 socket 文件,通过网卡发送数据,就是写 socket 文件;
DatagramPacket:UDP是面向数据报的,每次发送接收数据的基本单位,就是一个 UDP 数据报,DatagramPacket 就表示了一个 UDP 数据报;
2. UDP 回显服务器和客户端的实现
服务器的逻辑:
- 服务器是被动等待的一方,需要等到客户端发送请求;
- 服务器接收到请求后,需要解析请求,并根据请求计算响应;
- 响应计算完成之后,将响应按照 DatagramPacket 的形式进行封装,发送给客户端;
回显服务器:
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 {
this.socket = new DatagramSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
// 1. 创建 DatagramPacket 对象,用于接收客户端的请求
DatagramPacket requestDatagram = new DatagramPacket(new byte[4096], 4096);
// 2. 等待接收客户端的请求
socket.receive(requestDatagram);
// 3. 解析客户端请求
String request = new String(requestDatagram.getData(), 0, requestDatagram.getLength());
// 4. 接收到客户端的请求后,根据请求计算响应
String response = process(request);
// 5. 根据响应,构建 DatagramPacket 用于返回响应
DatagramPacket responseDatagram = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestDatagram.getSocketAddress());
// 6. 返回响应
socket.send(responseDatagram);
System.out.printf("[%s %d] request: %s, response: %s",
requestDatagram.getAddress().toString(), requestDatagram.getPort(), request, response);
System.out.println();
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}
可以看到,UDP 协议每次在收发数据的时候,都需要传入IP 地址和端口号,因此 UDP 是无连接的;
客户端的逻辑:
- 等待用户输入请求;
- 将请求按照 DatagramPacket 的形式进行封装,并发送给服务器,等待服务器返回响应;
- 拿到服务器响应后进行解析;
客户端:
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;
}
private void start() throws IOException {
System.out.println("客户端启动!");
Scanner in = new Scanner(System.in);
while(true){
// 1. 等待用户进行输入
System.out.print("-> ");
if(!in.hasNext()){
break;
}
String request = in.next();
// 2. 根据用户输入,构造 DatagramPacket 对象
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
// 3. 将构造好的请求发送给服务器
socket.send(requestPacket);
// 4. 构造 DatagramPacket 用于接收服务器响应
DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
// 5. 接收服务器返回的响应
socket.receive(responsePacket);
// 6. 解析响应,并打印
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();
}
}
3. 词典服务器的模拟实现
基于上述 UDP 回显服务器,可以实现一个带有一点业务逻辑的词典服务器,步骤如下:
- 继承 UDP 回显服务器;
- 重写 UDP 回显服务器的 process() 方法(业务逻辑);
代码如下:
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class UdpDictServer extends UdpEchoServer{
private Map<String, String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("cat", "猫咪");
dict.put("dog", "狗子");
// 此处可以无限添加英汉键值对
// 专业的词典程序本质上也是有一个非常大的,很多键值对的 HashMap
}
// start() 方法可以继承父类的,因为执行逻辑一样
// process() 需要重写,把查词典的逻辑加进去
@Override
public String process(String request) {
if(!dict.containsKey(request)){
return "您要查询的单词未收录~";
}
return dict.get(request);
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
3. TCP 编程
1. TCP 的核心 API
ServerSocket:这是 Socket 类,对应到网卡,这个类只能给服务器使用;
Socket:对应到网卡,既可以给服务器使用,又可以给客户端使用;
TCP 是面向字节流的,传输的基本单位是字节;
TCP 是有连接的:
- 服务器调用 accept() 方法,服务器内核就会等待客户端请求,完成创建连接的工作;
- 客户端调用 Socket(String serverIp, int port) 方法,内核会主动发送请求,建立 TCP 连接;
accept() 方法可能会产生阻塞,没有客户端请求,就会阻塞等待;
有客户端请求,accept() 就会返回一次,多个客户端请求,accept() 就需要返回多次;
2. TCP 回显服务器和客户端的实现
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;
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();
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()){
Scanner in = new Scanner(inputStream);
PrintWriter out = new PrintWriter(outputStream);
while(true){
// 1. 等待客户端发送请求
if(!in.hasNext()){
break;
}
String request = in.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 返回响应
out.println(response);
out.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 {
try {
System.out.printf("[%s %d] 客户端下线!\n", clientSocket.getInetAddress(), clientSocket.getPort());
clientSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
注意:
每个客户端创建请求都会创建一个 clientSocket,当客户端断开连接的时候,务必要关闭;否则会造成文件描述符表泄露;
TCP 是有连接的,服务器会保存客户端的信息,发送信息不需要指定客户端的 IP 地址和端口号;
TCP 是面向字节流的,每个 Socket 都会有一个 InputStream 和 一个 OutputStream;
使用 PrintWriter 包裹 OutputStream 时,数据都是写在内存缓冲区的,因此写完之后还要调用 flush() 方法,才能将数据写到网卡发送出去;
客户端的逻辑:
- 主动发起请求,建立 TCP 连接;
- 等待用户输入,生成请求;;
- 向服务器发送请求,等待响应;
- 接收响应并解析;
代码如下:
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 {
socket = new Socket(serverIp, serverPort);
}
private void start(){
System.out.println("客户端启动!");
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
Scanner in = new Scanner(System.in);
PrintWriter writer = new PrintWriter(outputStream);
Scanner reader = new Scanner(inputStream);
while(true){
System.out.print("-> ");
// 1. 等待用户输入请求
if(!in.hasNext()){
break;
}
String request = in.next();
// 2. 发送请求到服务器
writer.println(request);
writer.flush();
// 3. 等待服务器响应
String response = reader.next();
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();
}
}
上述代码还有个严重问题,就是不能支持多个客户端访问;
多个客户端需要调用 accept() 方法返回多个 Socket 对象,但是服务器程序运行起来后,会一直在 ProcessConnection() 方法中运行,程序无法返回到 accept() 方法,导致不能连接多个客户端;
解决这个问题可以采用多线程的方法:
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
// 等待客户端端建立连接
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {
ProcessConnection(clientSocket);
});
t.start();
}
}
这样每次由一个新的线程处理客户端的请求,就可以多个同时支持多个客户端访问;
但是当客户端的数量越来越多,可能会导致频繁创建和销毁线程,这样创建和销毁线程的开销就不容忽视了;
解决这个问题,可以使用线程池进行优化:
public void start() throws IOException {
System.out.println("服务器启动!");
while(true){
// 等待客户端端建立连接
Socket clientSocket = serverSocket.accept();
ExecutorService service = Executors.newCachedThreadPool();
service.submit(() -> {
ProcessConnection(clientSocket);
});
}
}
对于服务器:
- 如果每个客户端的请求处理时间很短,就是一个短连接;
- 每个客户端处理的时间较长,那么就是一个长连接;
如果客户端都是长连接,而且数量较多,服务器的负担会非常重;
解决上述问题,可以考虑使用协程或者 IO 多路复用的方式;