一,socket套接字
1.1 概念
socket 英文原意"插槽",最早是电脑上的一种接口
在操作系统中,给应用程序提供的一组网络编程的API称为 socket api
socket api ,操作系统给应用程序提供的函数,而应用程序---> 应用层 --> 和应用层交互的是传输层 ---> socket api都是传输层提供的
操作系统中,也把socket 理解成一种"特殊的文件",代指网卡(所以在socket中写数据,相当于往网卡中写,从socket中读数据相当于往网卡中读)
1.2 分类
1.2.1 流套接字
流套接字使用传输层TCP协议
TCP,即TransmissionControlProtocol(传输控制协议),传输层协议。
TCP的特点:有连接,可靠传输,面向字节流,全双工,有接收缓冲区,也有发送缓冲区,大小不限
对于字节流来说,可以简单的理解为,传输数据是基于IO流,流式数据的特征就是在IO流没有关闭的情况下,是无边界的数据,可以多次发送,也可以分开多次接收。
TCP中,在正式通信之前,把对方的信息获取到,保存下来
代码中,先调用"建立连接"的函数,然后再调用"发送数据"的函数
1.2.2 数据报套接字
数据报套接字使用传输层UDP协议
UDP,即UserDatagramProtocol(用户数据报协议),传输层协议。
UDP的特点:无连接 ,不可靠传输 ,面向数据报,有接收缓冲区,无发送缓冲区,大小受限,⼀次最多传输64k
对于数据报来说,可以简单的理解为,传输数据是⼀块⼀块的,发送⼀块数据假如100个字节,必须一次发送,接收也必须⼀次接收100个字节,而不能分100次,每次接收1个字节。
UDP则是不会保存对方的信息,每次调用"发送"接口的时候,现场传递这个信息
在代码中,直接调用"发送数据"的函数
1.2.3 原始套接字
原始套接字用于自定义传输层协议,用于读写内核没有处理的IP协议数据
1.3 TCP和UDP的区别
1.3.1 关于连接
而所谓的"连接",本质上就是通信双方,记录了对方的信息
AB两个主机,A记录了B的++关键信息++(IP和端口号),回头A看到,自己要和B通信
B也记录了A的关键信息,回头B看,也知道自己要和A通信
1.3.2 可靠传输和不可靠传输
不可靠传输:把数据发出去之后,就不管了
可靠传输:把数据发出去之后,关心一下对方是否收到了数据(并不能保证百分之百到达对方,不过可以知道对方是否收到了,如果没收到,TCP自动进行补救措施)
1.3.3 面向字节流和面向数据报
面向字节流:TCP的特点和文件比较类似,比如想发送100个字节的内容。可以一次性全发送,也可以分十次,一次发送10字节,也可以分20次发送,一次发5个字节等等多种情况
面向数据报:UDP传输的时候,以"数据报"为单位,进行传输,一次必须是发送/接受一个UDP数据报(数据报即由若干个字节构成的结构体)
1.3.4 全双工
全双工:同时进行双向通信
半双工:同一时刻,只能单向通信
二,UDP数据报套接字编程
2.1 API介绍
2.1.1 DatagramSocket
datagram:数据报
DatagramSocket是UDP Socket,用于发送和传输UDP数据报
创建一个这样的对象,就相当于在系统内核里创建了一个socket文件,创建socket文件,也会占用文件描述符表的一个位置,用完之后要关闭,否则可能会出现"文件资源泄露"
DatagramSocket构造方法
|--------------------------|----------------------------------------------|
| 方法签名 | 方法说明 |
| DatagramSocket() | 创建⼀个UDP数据报套接字的Socket,绑定到本机任 意⼀个随机端⼝(⼀般⽤于客⼾端) |
| DatagramSocket(int port) | 创建⼀个UDP数据报套接字的Socket,绑定到本机指 定的端⼝(⼀般⽤于服务端) |
DatagramSocket方法
|--------------------------------|---------------------------------|
| 方法签名 | 方法说明 |
| void receive(DatagramPacket p) | 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待) |
| void send(DatagramPacket p) | 从此套接字发送数据报包(不会阻塞等待,直接发送) |
| void close() | 关闭此数据报套接字 |
其中,DatagramPacket表示一个UDP数据报,是UDP进行网络通信传输的基本单位
2.1.2 DatagramPacket
DatagramPacket构造方法
|-----------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| 方法签名 | 方法说明 |
| DatagramPacket(byte\[\] buf, int length) | 构造⼀个DatagramPacket以⽤来接收数据报,接收的数据保存在字节数组(第⼀个参数buf)中,接收指定⻓度(第⼆个参数length) |
| DatagramPacket(byte\[\] buf, int offset, int length, SocketAddress address) | 构造⼀个DatagramPacket以⽤来发送数据报,发送的数据为字节数组(第⼀个参数buf)中,从0到指定⻓度(第⼆个参数length)。address指定⽬的主机的IP和端⼝号 |
DatagramPacket方法
|--------------------------|---------------------------------------------|
| 方法签名 | 方法说明 |
| InetAddress getAddress() | 从接收的数据报中,获取发送端主机IP地址;或从发 送的数据报中,获取接收端主机IP地址 |
| int getPort() | 从接收的数据报中,获取发送端主机的端⼝号;或从 发送的数据报中,获取接收端主机端⼝号 |
| byte\[\]getData() | 获取数据报中的数据 |
2.2 代码实例
编写一个最简单的 udp 客户端服务器程序
udp客户端给udp服务器发送一个字符串(用户控制台输入)
udp服务器收到之后,把这个字符串,原封不动的返回给客户端
形如这样的客户端 -> 服务器,然后服务器 -> 客户端,即回显服务器(echo server)
服务器
java
public class UdpEchoServer {
//首先先创建一个socket对象
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 requestPacket = new DatagramPacket(new byte[1024],1024);
socket.receive(requestPacket);
//为了处理方便,把里面的载荷数据获取出来,构造成一个String
String request = new String(requestPacket.getData(),0,requestPacket.getLength());
//2,根据请求构造响应
String response = process(request);
//3,把响应返回到客户端
DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,requestPacket.getSocketAddress());
socket.send(responsePacket);
//打印日志
System.out.printf("[%s:%d] req: %s; resp: %s\n",requestPacket.getAddress().toString(),responsePacket.getPort(),request,response);
}
}
//此处是回显服务器,响应就和请求完全一样
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
// 端口号是一个整数, 我们可以随意指定.
// 端口范围是 0-65535, < 1024 的端口一般也不使用
UdpEchoServer server = new UdpEchoServer(9090);
server.start();
}
}

其中,SocketException也包含在IOException中,最典型的形况就是端口号冲突,从而导致的异常
在这个代码中,通过端口号来区分应用程序
端口号是网络通信中的重要的概念,客户端想要访问服务器,就需要知道服务器的IP(IP比较容易确定,即在哪个机器上启动的服务器)和服务器的端口号(服务器启动的时候,由程序员指定的)

此处收到的UDP数据报,就会把载荷的内容解析出来后放到参数中的字节数组中
以上这段代码是服务器代码,是被动的一方,当服务器运行到receive的时候,客户端可能已经发了请求,也可能没法请求
如果客户端没有发数据,此时receive就会阻塞,一直等到客户端有数据发过来

这个代码是字节数组转换成字符串

这个代码是字符串转换成字节数组

UDP是无连接的,服务器的UDP协议中不保存客户端的信息(客户端IP和端口)
所以这样的代码并不知道数据要发给谁,需要在send的时候,通过参数来制定,这次要发给谁(数据从哪来的就发回到哪里去)
客户端
java
package network.udp;
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;
}
public void start() throws IOException {
System.out.println("客户端启动...");
// 客户端我们也想要让它持续运行.
// 每循环一次, 就从控制台读取到一个输入的数据, 把这个数据发送到服务器, 读取服务器的响应.
Scanner scanner = new Scanner(System.in);
while (true) {
// 1. 从控制台读取用户输入
System.out.print("-> ");
String request = scanner.next();
// 2. 构造请求, 发送给服务器
DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
InetAddress.getByName(serverIp), this.serverPort);
socket.send(requestPacket);
// 3. 读取服务器的响应
DatagramPacket responsePacket = new DatagramPacket(new byte[1024], 1024);
socket.receive(responsePacket);
String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
// 4. 打印出 response
System.out.println(response);
}
}
public static void main(String[] args) throws IOException {
// UdpEchoClient client = new UdpEchoClient("8.137.149.9", 9090);
UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
client.start();
}
}

在客户端构造socket的时候,没有指定端口号,但是这不意味着没有端口号,端口号是一定要有的,是由操作系统分配的一个空闲的端口
服务器必须要指定固定的端口 以确保客户端随时都能够找到服务器
但是客户端必须不指定固定端口,让系统随机分配空闲的端口,避免端口号出现冲突
英译中服务器
再写一个英译中服务器,请求发来英文单词,响应对应的中文翻译
需要先让服务器知道,英文对应的中文是什么
java
public class UdpDictServer extends UdpEchoServer{
private Map<String,String> dict = new HashMap<>();
public UdpDictServer(int port) throws SocketException {
super(port);
dict.put("hello","你好");
dict.put("cat", "小猫");
dict.put("dog", "小狗");
dict.put("pig", "小猪");
}
//重写process
public String process(String request){
//英文--中文
return dict.getOrDefault(request,"[没有找到该单词]");
}
public static void main(String[] args) throws IOException {
UdpDictServer server = new UdpDictServer(9090);
server.start();
}
}
其中,main方法中的start调用的是父类中的start
而父类的start中的
这个process即this.process()
this类型看起来是父类的类型,实际上指的是子类的实例
三,TCP流套接字编程
TCP是面向字节流的,所以TCP中数据传输的基本单位是字节,用byte,byte\[\]即可
3.1 API介绍
3.1.1 ServerSocket
ServerSocket是创建TCP服务端Socket的API
ServerSocket构造方法:
服务器启动的时候,指定具体的端口号
|------------------------|----------------------------|
| 方法签名 | 方法说明 |
| ServerSocket(int port) | 创建⼀个服务端流套接字Socket,并绑定到指定端⼝ |
ServerSocket方法:
accept是ServerSocket最关键的方法,用于辅助完成"连接"的建立
TCP是有连接的,TCP的连接建立流程是操作系统内核完成的
通过accept把内核里建立好的连接,拿到应用程序中然后才能通信
|-----------------|------------------------------------------------------------------------|
| 方法签名 | 方法说明 |
| Socket accept() | 开始监听指定端⼝(创建时绑定的端⼝),有客⼾端连接后,返回⼀个服务端Socket对象,并基于该 Socket建⽴与客⼾端的连接,否则阻塞等待 |
| void close() | 关闭此套接字 |
调用close是为了释放资源(文件描述符表)防止文件资源泄露,确定不再使用了才能释放
而对于UDP的服务器/客户端来说,得是进程结束才会不使用,但是进程结束时系统就自动释放了,所以不需要手动调用close
3.1.2 Socket
Socket是客户端socket,或服务器中接收到客户端建立连接(accept方法)的请求之后,返回的服务器socket
不管是客户端还是服务端socket,都是双方建立连接之后,保存的对端信息,及用来与对方发数据的
Socket构造方法:
|-------------------------------|-------------------------------------------|
| 方法签名 | 方法说明 |
| Socket(String host, int port) | 创建⼀个客⼾端流套接字Socket,并与对应IP的主机 上,对应端⼝的进程建⽴连接 |
Socket方法:
|--------------------------------|-------------|
| 方法签名 | 方法说明 |
| InetAddress getInetAddress() | 返回套接字所连接的地址 |
| InputStream getInputStream() | 返回此套接字的输⼊流 |
| OutputStream getOutputStream() | 返回此套接字的输出流 |
3.2 代码实例
服务器
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 {
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动...");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
// TCP 每次循环是要处理一个连接.
// 一个连接里, 可能会有多组请求和响应.
Socket socket = serverSocket.accept();
// 之前是直接调用, 会存在问题
// processConnection(socket);
// 现在改成创建新的线程, 由线程调用 processConnection
// Thread t = new Thread(() -> {
// processConnection(socket);
// });
// t.start();
// 还可以使用线程池的方式, 进一步减少频繁创建销毁线程的开销.
service.submit(() -> {
processConnection(socket);
});
}
}
private void processConnection(Socket socket) {
// 一个连接中, 可能会涉及到多组请求/响应的交互
System.out.printf("[%s:%d] 客户端上线!\n", socket.getInetAddress().toString(), socket.getPort());
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
// 一次循环, 就是处理一组请求和响应
while (true) {
// 1. 读取请求并解析
if (!scanner.hasNext()) {
// 判定当前 tcp 的连接是否断开了!
break;
}
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回给客户端
printWriter.println(response);
printWriter.flush();
// 4. 打印日志
System.out.printf("[%s:%d] req: %s; resp: %s\n",
socket.getInetAddress().toString(), socket.getPort(), request, response);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
System.out.printf("[%s:%d] 客户端下线!\n", socket.getInetAddress().toString(), socket.getPort());
// 可能会存在大量的业务逻辑, 消耗很多的时间.
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}

使用这个socket对象,通过getInputStream和getOutputStream获取到流对象,进一步和客户端通信(ServerSocket相当于在外面揽客的,不提供具体的服务,而Socket是给客户提供具体的服务的)
区分连接和链接:
Connection连接:虚拟的关系,通信双方保存对端的关键信息
Link链接:相当于"快捷方式",快捷方式中包含了指向应用程序的路径

在这个步骤的第一步中,对于UDP来说,读取到一个DatagramPacket 就是一个请求
但是对于TCP来说,一次读一个字节,一个请求,应该是包含多个字节?
为了明确哪里是一个请求结束,就需要做出约定,约定一个请求到底是多长,比如可以约定一个请求以 \n 为结尾
所以第一个方法为:
java
//1,读取请求并解析
StringBuilder request = new StringBuilder();
while(true){
int b = inputStream.read();
char c = (char) b;
if(c == '\n'){
break;
}
request.append(b);
}
第二种更为方便的方法为:
利用Scanner

由上可以看出Scanner的构造方法的参数是InputStream
Scanner并不是和"控制台"绑定的,而是和"InputStream"绑定
InputStream 可以是控制台,也可以是硬盘文件,还可以是socket文件
java
Scanner scannner = new Scanner(inputStream);
//一次循环就是处理一组请求和响应
while(true){
//1,读取请求并解析
String request = scannner.next();
next方法就是以 \n 为结束标志的,准确来说是以"空白符"作为结束标记,包括不限于空格,回车,换行,tab,垂直制表符,翻页符等等
步骤三的方法一:

此处返回响应的时候,需要给响应这里也加上 \n 作为结尾
客户端后续读的时候,也是要用 scanner.next(),所以也是要有明确的结束标记的
java
//3,把响应写回给客户端
response += "\n";
outputStream.write(response.getBytes());
第二个更好的方法为:
java
PrintWriter printWriter = new PrintWriter(outputStream);
//一次循环就是处理一组请求和响应
while(true){
//1,读取请求并解析
String request = scannner.next();
if(!scannner.hasNext()){
//判定当前 TCP 的连接是否断开
break;
}
//2,根据请求计算响应
String response = process(request);
//3,把响应写回给客户端
printWriter.println(response);
printWrite和System.out用法差不多
此处的println 是先把数据写入到缓冲区中,而不是直接写到网卡里,等到缓冲区攒了一波数据了,然后再写入IO设备
这样操作是因为计算机访问IO设备(控制台,硬盘,网络)效率比访问内存要低很多(差2到3个数量级),假设我们需要连续访问IO设备,总体的时间开销比一次性访问要多很多
但是在这个地方我们需要打印操作一执行,就立刻访问网卡,所以需要加上冲刷(强制把缓冲区的数据写入到IO设备):
java
//3,把响应写回给客户端
printWriter.println(response);
printWriter.flush();
客户端
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;
// 需要指定, 要连接的服务器的 IP 和 端口
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("客户端启动...");
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scannerConsole = new Scanner(System.in);
Scanner scannerNetwork = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
while (true) {
// 1. 从控制台读取用户输入
System.out.print("-> ");
String request = scannerConsole.next();
// 2. 构造请求, 发送给服务器
printWriter.println(request);
printWriter.flush();
// 3. 从服务器读取响应
if (!scannerNetwork.hasNext()) {
break;
}
String response = scannerNetwork.next();
// 4. 把响应显示到控制台上
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();
}
}
关于close的问题
socket是文件,需要被关闭
tcp 客户端的socket,不需要单独关闭,生命周期跟随进程
tcp 服务器的server socket 不需要单独关闭,生命周期跟随进程
tcp服务器的socket,需要单独关闭
1,socket 有很多个,每次有客户端连上来,都会创建新的socket
2,这个socket的生命周期,不是跟随进程,而是跟随tcp连接
随着时间的推移,连接上的客户端越来越多,长此以往,就会使文件描述符表越占越多
一个服务器需要能够给多个客户端提供服务
在以上代码运行之后会发现,当第一个客户端运行的时候,后面的其他的客户端就会卡住
这是因为,客户端1连接成功之后,服务器就会进入到processConnection的循环中,此时客户端1如果不发送请求,服务器就会阻塞在hasNext,如果客户端1发送请求,hasNext解除阻塞,继续执行,执行完毕之后,循环回来,继续hasNext阻塞,等待下一个请求,直到客户端退出,此时循环才结束
当服务器的代码,在processConnection中,不断的循环进行的时候,此时意味着无法第二次执行到accept了,即无法处理第二个客户端的请求
建立连接是内核的事情,连接建立了之后,代码如何处理,就是应用程序的事情,得accept了才能处理
Q:而如何做到又能等待accept处理新客户端的连接,又能等待hasNext处理当前客户端的请求呢?
A:多线程
主线程负责执行accept,每次有客户端连接上来了,创建新的线程,由新线程负责处理客户端的请求和响应,由N个客户端,就创建N个线程
服务器引入多线程
java
//启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(() -> {
processConnection(clientSocket);
});
t.start();
}
}
注意:在操作系统中,线程和线程之间,地位是平等的,没有"父子""主从"这样的关系,进程中,有父进程和子进程的说法
服务器引入线程池
为了避免频繁创建销毁线程,也可以引入线程池
java
// 启动服务器
public void start() throws IOException {
System.out.println("服务器启动!");
ExecutorService service = Executors.newCachedThreadPool();
while (true) {
Socket clientSocket = serverSocket.accept();
// 使⽤线程池, 来解决上述问题
service.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
四,长短连接
现在的时代是"高并发,高可用,大数据"
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,⼀直保持连接状态,双⽅不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
对比以上长短连接,两者区别如下:
- 建立连接、关闭连接的耗时:短连接每次请求、响应都需要建⽴连接,关闭连接;⽽⻓连接只需要 第⼀次建⽴连接,之后的请求、响应都可以直接传输。相对来说建⽴连接,关闭连接也是要耗时 的,⻓连接效率更⾼。
- 主动发送请求不同:短连接⼀般是客⼾端主动向服务端发送请求;⽽⻓连接可以是客⼾端主动发送 请求,也可以是服务端主动发。
- 两者的使⽤场景有不同:短连接适⽤于客⼾端请求频率不⾼的场景,如浏览⽹⻚等。⻓连接适⽤于 客⼾端与服务端通信频繁的场景,如聊天室,实时游戏等。
基于BIO(同步阻塞IO)的⻓连接会⼀直占⽤系统资源。对于并发要求很⾼的服务端系统来说,这样的 消耗是不能承受的。
由于每个连接都需要不停的阻塞等待接收数据,所以每个连接都会在⼀个线程中运⾏。 ⼀次阻塞等待对应着⼀次请求、响应,不停处理也就是⻓连接的特性:⼀直不关闭连接,不停的处理 请求。
实际应⽤时,服务端⼀般是基于NIO(即同步⾮阻塞IO)来实现⻓连接,性能可以极⼤的提升