文章目录
- 一、网络编程基础
- [二、Socket 套接字](#二、Socket 套接字)
一、网络编程基础
什么是网络编程?
你有没有想过,当我们在网页上打开一个视频,这里的视频是从哪里获取到的?本地?还是浏览器?其实是通过网络获取到视频资源(可以通过网络传输各种不同的资源:视频资源、图片资源、文本资源等等),而传输资源 就要通过网络编程来实现。
那什么是网络编程呢?指的是网络上的主机通过不同的进程 ,以编程的方式实现网络通信/网络数据传输。只要满足不同进程,即使是同一个主机上的多个进程相互进行网络通信也是可以的。
特殊的情况:对于开发,当条件有限时,也可以通过同一个主机运行多个进程(指定哪一个进程提供网络资源、哪一个进程获取网络资源)来完成网络编程。
基本概念
发送端和接收端
在一次网络数据传输的时候,发送端 是指网络数据的发送方进程,发送端主机就是网络通信的源主机;接收端是指网络数据的接收方进程,发送端主机就是网络通信的目的主机。
发送端和接收端只是相对的,只是⼀次网络数据传输产生数据流向后的概念。
请求和响应
一般来说,获取一个网络资源的过程,涉及到两次网络数据传输:
- 第一次是 请求数据 的发送
- 第二次是 响应数据 的发送
接收方主机的某个进程 发送方主机的某个进程 接收方主机的某个进程 发送方主机的某个进程 请求 响应
客户端和服务端
常见的网络数据传输场景下,我们把提供服务 的一方进程称为服务端 ,它一般是 7*24 对外提供服务;而获取服务 的一方进程通常是客户端。
服务端主机的某个进程 客户端主机的某个进程 服务端主机的某个进程 客户端主机的某个进程 客户端获取服务资源 客户端保存资源在服务端 保存资源 请求:获取服务资源 响应:返回服务资源 请求:保存用户资源 响应:返回处理结果
常见的客户端服务端模型
客户端是给用户使用的程序,而服务端是提供用户服务的程序,一次交互的过程如下:
- 用户使用客户端程序的时候,客户端会发送请求给服务端
- 服务端接收到请求后会根据请求数据,处理相应的业务,然后返回响应
- 客户端根基接收到的响应数据,展示获取到的资源(如网页)
服务端主机的某个进程 客户端主机的某个进程 服务端主机的某个进程 客户端主机的某个进程 处理业务 展示获取到的资源 请求:获取服务资源 响应:返回服务资源
二、Socket 套接字
概念
Socket 套接字,是由系统提供用于网络通信的技术,是基于 TCP/IP 协议的网络通信的基本操作单元。基于 Socket 套接字的网络程序开发就是网络编程。
分类
Socket 套接字主要针对传输层协议划分为以下三类:
- 流套接字 :使用传输层
TCP 协议(Transmission Control Protocol,传输控制协议)进行网络编程。TCP 协议的主要特点是:有连接、可靠传输、面向字节流、有发送/接收缓冲区、大小不限。对字节流来说,传输数据是基于 IO 流,其特征是在未关闭的情况下是无边界的数据,可以多次发送,也可以分开多次接收。 - 数据报套接字 :使用传输层
UDP 协议(User Datagram Protocol,用户数据报协议)进行网络编程。UDP 协议的主要特点是:无连接、不可靠传输、面向数据报、有发送/接收缓冲区、大小受限(一次传输最多 64 kb)。对于数据报来说,发送一整个数据报,同时也必须接收一整个数据报,不能分开接收同一数据报中的数据。 - 原始套接字:原始套接字用于自定义传输层协议,用于读写内核没有处理的 IP 协议数据,这里就不过多介绍,读者可以自己搜索学习。
Java 数据报套接字通信模型
对于 UDP 协议来说,通信无需建立连接,一次通信发送/接收整个数据报。
Java 中使用 UDP 进行通信,主要通过 DatagramSocket 类创建数据报套接字,并使用 DatagramPacket 作为发送/接收的 UDP 数据报。
主要通信流程:
服务端 客户端 服务端 客户端 1. 创建 DatagramSocket 1. 创建 DatagramSocket 2. 构造 DatagramPacket, 其中包含要发送的数据, 以及 IP 地址和端口号 2. 构造 DatagramPacket, 包含一个空的字节数组, 用于保存接收到的数据 调用 socket.receive(packet) 准备接收数据报 未接收到数据报时阻塞等待 socket.send(packet) 4. 接收 UDP 数据报 5. 将接收到的数据报 存入 DatagramPacket 中的 字节数组内 3. 发送 UDP 数据报
上述流程只是一次发送 UDP 数据报的过程,也就是只有请求没有返回响应。
对于一般的业务流程来说,是有请求也有响应的:
服务端 客户端 服务端 客户端 1. 创建 DatagramSocket 1. 创建 DatagramSocket 2. 构造 DatagramPacket, 其中包含要发送的数据, 以及 IP 地址和端口号 2. 构造 DatagramPacket, 包含一个空的字节数组, 用于保存接收到的数据 调用 socket.receive(packet) 准备接收数据报 未接收到数据报时阻塞等待 socket.send(packet) 4. 构造 DatagramPacket, 其中包含一个空的字节数组, 用于存放接收到的响应数据 5. 接收 UDP 数据报 6. 将接收到的数据报 存入 DatagramPacket 中的 字节数组内 7. 根据数据内容 执行相关的业务操作 8. 根据处理好的业务构造响应: 构造 DatagramPacket, 其中包含返回的业务数据 以及 IP 地址、端口号 socket.send(packet) 10. 接收 UDP 数据报 11. 将接收到的 UDP 数据报 存入 DatagramPacket 中的 字节数组内 12. 根据响应中的数据 处理接下来的业务 3. 发送 UDP 数据报(请求) 9. 发送 UDP 数据报(响应)
Java 流套接字通信模型
对于 TCP 协议来说,通信之前需要进行连接。
服务端通过 ServerSocket 类获取客户端的连接 ,通过 Socket 类与客户端进行通信 。通过输入输出流: InputStream 和 OutputStream 来传输网络资源。
具体的通信流程如下:
服务端 客户端 服务端 客户端 1. 创建 Socket 1. 创建 ServerSocket, 通过 accept() 方法 获取连接请求 3. 接收连接请求后, 创建 Socket, 用于与客户端通信 6. 关闭 Socket 及相关资源 6. 关闭 Socket 及相关资源 2. 向服务端发送连接请求 4. 通过 OutputStream 发送请求数据, 通过 InputStream 接收响应数据 4. 通过 InputStream 接收请求数据, 通过 OutputStream 发送响应数据 5. 结束通信: socket.close()
UDP 数据报套接字编程
API 介绍
DatagramSocket
DatagramSocket 是 UDP 协议用于发送和接收 UDP 数据报的类。
- 构造方法:
| 方法签名 | 方法说明 |
|---|---|
| DatagramSocket() | 创建一个用于 UDP 数据报套接字的 Socket, 绑定到本机的任意端口号(客户端采用此方法构造) |
| DatagramSocket(int port) | 创建一个用于 UDP 数据报套接字的 Socket, 绑定到本机的指定端口号(服务端采用此方法构造) |
- 常用方法:
| 方法签名 | 方法说明 |
|---|---|
| void receive(DatagramPacket packet) | 通过 socket 接收 UDP 数据报, 若没有接收到数据报,就会阻塞等待 |
| void send(DatagramPacket packet) | 通过 socket 发送 UDP 数据报,该方法不会阻塞等待 |
| void close() | 关闭 socket |
DatagramPacket
DatagramPacket 是通过 UDP socket 发送或接收的数据报, 里面包含数据内容、IP 地址及端口号。
- 构造方法:
| 方法签名 | 方法说明 |
|---|---|
| DatagramPacket(byte[] buf, int length) | 构造一个用于接收数据报的 packet, 接收到的数据保存在字节数组 buf 中, 可以指定接收的长度 |
| DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) | 构造一个用于发送数据报的 packet, 要发送的数据被存在字节数组 buf 中, 长度是 0 offset 到 length, 使用 address 指定目的主机 IP 和目的端口号 |
- 常用方法:
| 方法签名 | 方法说明 |
|---|---|
| InetAddress getAddress() | 从接收到的数据报中获取发送端的主机 IP 地址, 或者从要发送的数据报中获取接收端的主机 IP 地址 |
| int getPort() | 从接收到的数据报中获取发送端的主机端口号, 或者从要发送的数据报中获取接收端的主机端口号 |
| byte[] getData() | 从数据报中获取数据 |
InetSocketAddress
在构造发送的响应数据报时,可以通过 InetSocketAddress来传入目的主机 IP 地址和端口号。
InetSocketAddress 是 SocketAddress 的子类,构造方法:
| 方法签名 | 方法说明 |
|---|---|
| InetSocketAddress (InetAddress addr, int port) | 创建一个 Socket 地址, 包含 IP 地址和端口号 |
回显服务器 - 代码示例
服务端
Java
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表示UDP的数据报,传入字节数组用于保存UDP的载荷部分
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
socket.receive(requestPacket);
// 把读取到的二进制数据转成字符串
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求计算响应(最重要最关键的逻辑)
// 此处写的是回显服务器,只需要把接收的直接返回即可
String response = process(request);
// 3. 将响应返回给客户端
// 根据字符串response构造DatagramPacket数据报,返回给客户端
// 此处第二个参数必须是response.getBytes().length(),不能是response.length()
// 由于UDP不会包含客户端信息,因此需要指定目的ip和目的端口
// 在接收到的requestPacket中有源ip和源端口,作为responsePacket的目的ip和目的端口
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(), response.getBytes().length,
requestPacket.getSocketAddress());
socket.send(responsePacket);
// 4. 打印日志
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();
}
}
客户端
Java
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;
// 由于UDP不保存对端的信息,因此创建变量保存一下目的ip和目的端口
private String serverIp;
private int serverPort;
// 构造方法要指定目的ip和目的端口
public UdpEchoClient(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket(); // 这里实例化对象要用无参的构造方法,让系统自动分配端口号
}
public void start() throws IOException {
Scanner in = new Scanner(System.in);
while (true) {
System.out.println("请输入要发送的内容:");
// 若用户没有输入信息就退出
if (!in.hasNext()) {
break;
}
// 1. 从控制台中读取用户输入的信息
String request = in.next();
// 2. 构造 DatagramPacket对象,并发送给服务器
// 构造过程中除了载荷,还有服务器的ip和端口号
// 这里的服务器ip的设置需要通过InetAddress转换一下
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), serverPort);
// 3. 发送数据报
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();
}
}
在服务端的 "根据请求处理业务" 的部分我们示例中封装成了一个方法 "process" 来完成,如果想要写一个英译汉的服务器,只需要继承原来的服务器然后重写process 方法即可,无需修改别的地方。
Java
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
public class UDPDictServer extends UdpEchoServer {
private HashMap<String, String> dict = new HashMap<>();
public UDPDictServer(int port) throws SocketException {
super(port);
// 初始化词典
dict.put("小猫", "cat");
dict.put("小狗", "dog");
dict.put("小鱼", "fish");
dict.put("小鸭", "duck");
dict.put("小猴", "monkey");
}
@Override
public String process(String request) {
return dict.getOrDefault(request, "未找到该词典");
}
public static void main(String[] args) throws IOException {
UDPDictServer server = new UDPDictServer(9090);
server.start();
}
}
TCP 流套接字编程
API 介绍
ServerSocket
ServerSocket 是创建 TCP 服务端 Socket 的类,可以理解为专门给服务端使用的 socket。
- 构造方法:
| 方法签名 | 方法说明 |
|---|---|
| ServerSocket(int port) | 创建一个服务端流套接字的 Socket, 绑定到指定端口号 |
- 常用方法:
| 方法签名 | 方法说明 |
|---|---|
| Socket accept() | 调用该方法后开始监听指定窗口, 当有客户端请求连接, 就会返回一个服务端 Socket 对象, 并基于该 Socket 建立与客户端的连接, 否则阻塞等待 |
| void close() | 关闭此套接字 |
Socket
Socket 是客户端 Socket, 或者是服务端接收到客户端建立连接的请求之后返回的服务端 Socket。
不管是服务端还是客户端,都是双方建立连接之后保存对端信息并用来与对方收发数据的。
- 构造方法:
| 方法签名 | 方法说明 |
|---|---|
| Socket(String host, int port) | 创建一个客户端流套接字 Socket, 并与对应 IP 的主机上的对应端口号的进程建立连接 |
- 常用方法:
| 方法签名 | 方法说明 |
|---|---|
| InetAddress getInetAddress() | 返回套接字所连接的地址 |
| InputStream getInputStream() | 返回此套接字的输入流 |
| OutputStream getOutputStream() | 返回此套接字的输出流 |
回显服务器 - 代码示例
服务端
Java
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 {
// 服务器专用socket
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) throws IOException {
System.out.printf("[%s:%d] 客户端上线\n", clientSocket.getInetAddress(), clientSocket.getPort());
// 使用字节流读取数据
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 借助Scanner读取字节流可以直接得到字符
Scanner scanner = new Scanner(inputStream);
// 借助PrintWriter写入字符到输出流
PrintWriter writer = new PrintWriter(outputStream);
while (true) {
// 分成三个步骤
// 1. 读取请求并解析(可以read也可以使用Scanner)
if (!scanner.hasNext()) {
// 与客户端断开连接
System.out.printf("[%s:%d] 客户端下线\n", clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
String request = scanner.next();
// 2. 根据请求计算响应,这里直接返回request
String response = process(request);
// 3. 返回响应给客户端(可以直接使用outputStream的write方法写入字节也可以借助PrintWriter直接写入字符)
//outputStream.write(response.getBytes());
writer.println(response);
writer.flush(); // 刷新缓冲区确保写入网卡
// 4. 打印日志
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 {
TCPEchoServer tcpEchoServer = new TCPEchoServer(9090);
tcpEchoServer.start();
}
}
客户端
Java
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和端口号,TCP内部就会记录下来对端信息
socket = new Socket(serverIp, serverPort);
}
public void start() {
// 从控制台获取到请求,发送给服务器
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
// 借助字符流操作对象来读写数据
Scanner scannerNet = new Scanner(inputStream);
PrintWriter writer = new PrintWriter(outputStream);
while (true) {
// 1. 读取输入
System.out.println("请输入要发送的内容:");
String request = scanner.next();
// 2. 发送给服务器
writer.println(request);
writer.flush(); // 刷新缓冲区确保写入网卡
// 3. 接收服务器的响应
String response = scannerNet.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();
}
}
服务端引入多线程
上面的服务端代码只能给一个客户端提供服务,无法同时服务多个客户端,因此我们引入多线程:每一个线程处理一个客户端的业务。
Java
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
// 多线程解决主线程每次只能accept一个客户端的问题
Socket clientSocket = serverSocket.accept();
// 主线程每accept一个客户端,就创建一个线程,让该线程去负责处理当前客户端的请求
Thread t = new Thread(() -> {
processConnection(clientSocket);
});
t.start();
}
}
服务端引入线程池
但是,客户端数目一般是很大的,如果频繁创建线程,消耗的资源会比较多,这时候我们引入线程池:减少创建线程的资源消耗。
Java
public void start() throws IOException {
System.out.println("服务器启动");
// 这里不适用固定线程的线程池,因为客户端的数目是不固定的
ExecutorService executorService = Executors.newCachedThreadPool();
while (true) {
// 引入线程池解决资源消耗大的问题
Socket clientSocket = serverSocket.accept();
// 将任务通过submit方法上传至线程池
executorService.submit(() -> {
processConnection(clientSocket);
});
}
}
长短链接
TCP 发送数据之前需要先建立连接,而什么时候关闭连接 决定了本次连接是长连接 还是短连接。
- 短连接:每一次接收到数据之后就关闭连接,只能收发数据一次。
- 长连接:不关闭连接,一直保持连接状态,通信双方不停地收发数据,可以收发数据多次。
长短连接的区别:
- 建立连接与关闭连接的耗时:短连接每一次发送请求/响应都需要建立连接、关闭连接;长连接只需要建立一次连接,之后发送的请求和响应都无需再建立连接。建立连接和关闭连接也是有耗时的,这样来看长连接效率更高。
- 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送请求、也可以是服务端主动发送请求。
- 使用场景不同:短连接适用于客户端发送请求的频率不高的场景(如浏览网页等);长连接适用于客户端与服务端通信频繁的场景(如聊天室、实时游戏等)。
文章到这里就告一段落啦,若有错误请尽管指出~
完