一、什么是网络编程套接字
这一章节主要讲网络编程实现跨主机通信,我们在应用层写程序需要关心的是传输层的 API(socket api,网络编程套接字),因为传输层到物理层已经由操作系统实现。
网络通信数据报(UDP)/包(IP)/帧(数据链路层协议)/段(TCP),我们不深究,只有做学术研究时才这么严谨。
传输层涉及到 TCP 协议和 UDP 协议,提供了两组 socket api。
TCP 与 UDP 的区别:
- TCP:有连接,可靠传输,面向字节流,全双工。
- UDP:无连接,不可靠传输,面向数据报,全双工。
各种词的解释:
- 有连接:通信双方会保存对方的信息。无连接:通信双方不保存对方的信息。(若要保存,需要应用层自己写代码)
- 可靠传输:尽可能保证数据报被对端收到。不可靠传输:数据报发出去后就不管了。
- 面向字节流:读写数据的基本单位是一个字节。面向数据报:读写数据的基本单位是一个数据报(由几个字节构成的结构化数据)。
- 全双工:一个信道,双向通信。半双工:一个信道,单向通信。
- 安全:数据不容易被黑客截获/破解。
二、UDP socket api
1、DatagramSocket
创建一个 DatagramSocket 对象就是打开了一个 socket 文件,socket 文件就是网卡。通过网卡发送数据就是通过该对象写入数据;通过网卡接收数据就是通过该对象读取数据。
构造方法:
- DatagramSocket():创建 UDP 数据报的套接字,随机一个端口绑定到本机。
- DatagramSocket(int port):创建 UDP 数据报的套接字,指定一个端口绑定到本机。
其它方法:
- void receive(DatagramPacket p):从该套接字接收数据报,没有接收到就阻塞。
- void send(DatagramPacket p):从该套接字发送数据报。
- void close():关闭该套接字。
2、DatagramPacket
该对象就是一个 UDP 数据报,是 UDP 协议的基本传输单位。
构造方法:
- DatagramPacket(byte[] buf, int length):接收到的数据存放在字节数组 buf 中,接收指定出长度。
- DatagramPacket(byte[] buf, int offset, int length, SocketAddress address):从 offset 开始存放数据;address 是目的主机的 IP 和端口号。
发送数据时,需要指定目标 IP 和端口号 SocketAddress,可以用 InetSocketAddress 来创建:
- InetSocketAddress(InetAddress addr, int port)。
其他方法:
- InetAddress getAddress():从接收数据报中,获取发送端的 IP 地址;从发送数据报中,获取接收端的 IP 地址。
- int get Port():获取端口号。
- byte[] getData():获取数据报中的数据。
3、实现一个简单的网络通信程序
基本流程:
客户端:
- 从控制台读取用户输入内容。
- 将内容通过网络发送给服务器。
服务器:
- 从客户端读取到请求内容。
- 根据请求内容计算响应。
- 把响应返回给客户端。
客户端:
- 从服务器读取到响应。
- 把响应结果显示到控制台上。
业务逻辑不是我们当前关注的重点,这会在后续重点学习,因此只实现简单的回显功能,将接收的原字符串再发送回去。
服务器:
- 创建 socket 时需要绑定一个端口号,来区分同一个设备上的不同的程序。一个端口号一个时刻,只能被一个进程(socket)绑定,所以我们要避开已经被使用的端口。可以用 netstat -ano 来查看现有程序所绑定的端口,可以用 netstat -ano | findstr "端口号" 来查看某端口是否被使用。端口号在网络协议中,使用 2 个字节的无符号整数来表示(0~65535),其中 0~1024 是知名端口号,供一些知名协议的服务器使用,我们写程序时应该避免使用。虽然我们是用 int 来存储端口号,但是 Java 会对端口号的范围进行检查,不符合规定则会报错。
- 启动服务器,目的是不断处理各种客户端的各种请求,需要使用循环实现。
- 循环当中,socket 会不断接收请求,但没有请求时,就会阻塞。
- 计算好响应后,需要把字符串构造为数据报 DatagramPacket 的形式再返回,用 String 的字节数组来构造,注意要传入字节数组的长度,而不是字符串的长度。
- 返回的时候,我们需要知道目的 IP 和端口号传给 DatagramPacket,才知道发送到哪。但是 UDP 是无连接的,所以 socket 不含对方的信息。但是从客户端发来的请求 DatagramPacket 中包含它的 IP 和端口,虽然被操作系解析掉了,但是还是可以通过 getSocketAddress() 方法来获取。
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
public class EchoServer {
private DatagramSocket socket = null;
public EchoServer(int port) throws SocketException {
socket = new DatagramSocket(port);
}
public void start() throws IOException {
while(true) {
// 1. 从客户端接收请求
// 1). 创建 DatagramPacket 对象,用于存放接收的请求数据
DatagramPacket requestPacket = new DatagramPacket(new byte[4096], 4096);
// 2). 调用 receive 方法,读取网卡中的请求数据
socket.receive(requestPacket);
// 3). 将 DatagramPacket 解析为字符串
String request = new String(requestPacket.getData(), 0, requestPacket.getLength());
// 2. 根据请求计算响应
String response = process(request);
// 3. 将响应返回给客户端
// 1). 将字符串响应转换为 DatagramPacket 对象
// 从请求中获取客户端的 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(), requestPacket.getPort(), request, response);
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
EchoServer server = new EchoServer(9090);
server.start();
}
}
从上面的代码中,可以看出 UDP 的其中三个特性:
- 无连接:socket 没有建立连接,直接可以使用 receive 和 send。
- 面向数据报:传输的基本单位是 DatagramPocket。
- 全双工:一个 socket 既可以接收也可以发送。
客户端:
- 客户端的 socket 不需要指定端口号,因为那是客户的电脑程序员也管不着,并且防止客户端的端口号冲突,所以不设置端口号,客户的设备上的操作系统会随机分配一个端口号。
- 客户端作为主动发送请求的一方,需要给要发送的 DatagramPacket 设置服务器的 IP 和端口号。
- 127.0.0.1 是本地回环 IP,当客户端和服务器在同一个主机上时,客户端可以用回环 IP 向本机的服务器发送请求。
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 EchoCilent {
private DatagramSocket socket = null;
private String serverIp;
private int serverPort;
public EchoCilent(String serverIp, int serverPort) throws SocketException {
this.serverIp = serverIp;
this.serverPort = serverPort;
socket = new DatagramSocket();
}
public void start() throws IOException {
System.out.println("客户端启动!");
// 1. 从控制台接收用户输入
Scanner scanner = new Scanner(System.in);
System.out.printf("> ");
String request = scanner.nextLine();
// 2. 将字符串转换为 DatagramPacket 对象
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());
// 6. 打印响应结果
System.out.println(response);
scanner.close();
}
public static void main(String[] args) throws IOException {
EchoCilent client = new EchoCilent("127.0.0.1", 9090);
client.start();
}
}
运行结果:
如果想所有主机上的客户端都能向服务器发送请求,那么就得把打包好的服务器程序 jar 包放到云服务器上运行,并设置防火墙,让服务器上指定端口的服务器程序能被外界访问。
我们还可以写其他的服务器程序,将 process 的逻辑改为将单词翻译成英文的功能,但是 start 还是一样的,所以可以继承 EchoServer 重写 process 方法:
import java.io.IOException;
import java.net.SocketException;
import java.util.HashMap;
import java.util.Map;
public class DictServer extends EchoServer{
private Map<String, String> dict = new HashMap<>();
public DictServer(int port) throws SocketException {
super(port);
dict.put("小猫", "cat");
dict.put("小狗", "dog");
dict.put("小鸟", "bird");
dict.put("小猪", "pig");
}
@Override
public String process(String request) {
return dict.getOrDefault(request, "未找到该单词的英文");
}
public static void main(String[] args) throws IOException {
DictServer server = new DictServer(9090);
server.start();
}
}
三、TCP socket api
1、ServerSocket
供服务器使用,不负责发送和接收数据,主要负责建立连接。TCP 不能直接读写数据,需要先建立连接。而建立连接的过程由操作系统内核完成,我们只需要调用方法把建立好的连接拿来使用。
构造方法:
- ServerSocket(int port):创建服务端流套接字,绑定指定端口。
其它方法:
- Socket accept():监听绑定的指定端口,有客户端连接后,返回一个服务端 Socket,使用该 Socket 与客户端建立连接;没有则阻塞等待。
- void close():关闭该套接字。
2、Socket
供服务器和客户端使用,负责发送和接收数据。TCP 以字节流为网络传输的基本单位,跟文件 IO 一致,所以 TCP 网络传输的读写也是通过 InputStream 和 OutputStream 展开。
构造方法:
- Socket(String host, int port):创建客户端流的套接字,与指定 IP 和端口的进行建立连接。
其他方法:
- InetAddress getInetAddress():返回 Socket 连接的 IP。
- InputStream getInputStream():返回 Socket 的输入流。
- OutputStream getOutputStream():返回 Socket 的输出流。
3、实现一个回显功能的网络通信程序
服务器:
ServerSocket 获取服务器套接字;serverSocket.accept() 获取与每个客户端建立了连接的套接字。
外循环,不断处理不同的客户端连接。
socket.getInputStream()、socket.getOutputStream() 获取每个连接的字节输入流、输出流。
为了省略解析请求数据为字符串、包装响应数据为字节格式的繁琐流程,使用 Scanner (System.in 也是属于 InputStream)和 PrintWriter 来包装字节输入、输出流,直接以字符串的格式读取和写入。
内循环,不断处理同一个客户端的多个请求响应操作。
不同协议使用同一端口不会冲突。
因为读写内存比外存快得多,所以每次 println 只是将数据写入了缓冲区,缓冲区满了才会自动写入外存上的网卡文件。所以我们需要 flush(),手动刷新缓冲区,将剩余数据写入网卡。避免因响应数据全部写入了缓冲区,但缓冲区未满,而没有写入网卡,造成的没有响应发送给客户端的错误。
文件资源泄露问题。外循环内,会频繁处理与多个客户端的连接 Socket,如果处理完后不关闭,就会占满文件描述符导致后续连接失败,所以需要 close。而 UDP 中的服务器、客户端的 DatagramSocket ,以及 TCP 服务器的 ServerSocket、客户端的 Socket 因为是全局的,只有在程序结束前需要关闭,但是程序结束后会自动销毁文件描述符,相当于自动关闭了。而Scanner 和 PrintWriter 是为了包装 InputStream 和 OutputStream,实际打开的是与每个客户端连接的 Socket(try-with-resource),所以 Scanner、PrintWriter 不持有文件描述符。
无法并发执行多个客户端的问题。 外循环处理不同的客户端连接,当第一个客户端连接的请求响应未结束,程序就会一直处于内循环中,导致第二个客户端的连接无法处理。所以我们要用到多线程,每处理一个客户端连接就开启一个线程。但是这样会导致线程频繁创建和销毁,所以我们又要用到线程池(注意,不要用 newFixedThreadPool,会限制客户端的并发数目。用 newCachedThreadPool,最大线程数目是 Integer.MAX_VALUE)。
但是如果同一时刻有多个客户端连接,并且会持续存在一段时间,那么就会有大量线程,但操作系统能处理的线程数目并不是无限的。在线程数目受限的情况下,使用 IO 多路复用,让一个线程等待多个 socket,哪个 socket 有数据就去处理。这个的实现在 Java 标准库 NIO 中,很难用,还有第三方库 Netty 对 NIO 进行了封装和简化。但实际应用中很少会使用,因为像 Spring 这种框架已经在底层的 http 服务器的底层使用了 NIO。
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 EchoServer {
private ServerSocket serverSocket;
public EchoServer(int port) throws IOException {
// 服务器流套接字,绑定端口
serverSocket = new ServerSocket(port);
}
public void start() throws IOException{
System.out.println("server start!");
ExecutorService executorService = Executors.newCachedThreadPool();
while(true) {
// 获取与客户端的连接套接字
Socket socket = serverSocket.accept();
// 处理连接,多线程并发执行
// Thread thread = new Thread(() -> {
// processConnection(socket);
// });
// thread.start();
// 线程池,避免频繁的线程销毁和创建
executorService.submit(() -> {
processConnection(socket);
});
}
}
private void processConnection(Socket socket) {
System.out.printf("[%s:%d] cilent online!\n", socket.getInetAddress(), socket.getPort());
// 打开网卡的字节输入、输出流
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
// 用 Scanner、PrintWriter 包装,避免繁琐的 字节数据与字符串的转换
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
// 内循环,处理同一个客户端连接的多个请求响应
while(true) {
// 读取客户端发送的请求,没有则退出
if (!scanner.hasNext()) {
System.out.printf("[%s:%d] cilent offline!\n", socket.getInetAddress(), socket.getPort());
break;
}
String request = scanner.nextLine();
// 计算响应
String response = process(request);
// 把响应写回客户端
printWriter.println(response);
// 刷新缓冲区,避免缓冲区未满的情况导致数据未写入网卡文件
printWriter.flush();
// 打印日志
System.out.printf("[%s:%d] req: %s; resp: %s\n", socket.getInetAddress(), socket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
EchoServer echoServer = new EchoServer(9090);
echoServer.start();
}
}
客户端:
- 建立连接需要指定目标进程的 IP 和端口号。
- 配置允许多个客户端进程并发执行:
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 EchoCilent {
private Socket socket;
public EchoCilent(String serverIp, int serverPort) throws IOException {
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("cilent start!");
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream();
Scanner scanner = new Scanner(System.in)) {
Scanner input = new Scanner(inputStream); // 用于读取服务器响应
PrintWriter output = new PrintWriter(outputStream); // 用于发送请求
// 进行多次发起请求,接收响应
while(true) {
// 接受用户输入,作为请求
System.out.printf("> ");
String request = scanner.nextLine();
// 发送请求
output.println(request);
output.flush(); // 确保请求被立即发送
if(!input.hasNextLine()) {
System.out.println("server disconnect!");
break;
}
// 读取服务器响应
String response = input.nextLine();
// 打印响应
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
EchoCilent echoCilent = new EchoCilent("127.0.0.1", 9090);
echoCilent.start();
}
}
运行结果: