我们编写网络程序涉及数据传输时,应用层需要调用传输层提供的api进行数据传输。原本系统给应用程序提供的api是C风格的,但是我们JDK针对这些api进行封装,形成Java风格的api。
系统对于传输层提供的socket api主要分为两组:基于UDP协议的api和基于TCP协议的api。以下介绍TCP协议的相关特性与核心机制以及基于TCP协议的字典翻译客户端与服务器实现。
socket(套接字): 是由系统提供用于网络通信的技术,是基于TCP/IP协议的网络通信的基本操作单元。基于Socket套接字的网络程序开发就是网络编程。
TCP协议 即Transmission Control Protocol,传输控制协议。主要对数据的传输进行一个详细的控制,是传输层的重点协议之一。
TCP协议段格式:
以上是教科书上为了排版的格式,实际格式如下:
解释说明:
源/目的端口号:表示数据是从哪个进程来,到哪个进程去。
32位序号/32位确认号:用于TCP的确认应答机制,以下会详细介绍。
头部长度:表示该TCP报头有多长。
6位标志位:
16位窗口大小:用于TCP的流量控制机制,流量控制的单位为字节数,这个值是本端期望一次接收的字节数。以下将详细说明。
16位校验和:发送端填充,接收端对TCP头部和TCP数据进行校验和计算,对收到的数据包进行验证。校验不通过,则认为数据有问题。
16位紧急指针:标识哪部分数据是紧急数据
注意: 上述是TCP报文首部必须的字段,也称固有字段,长度为20个字节。
可选项和填充部分:可选项和填充部分的长度为4n字节(n是整数),该部分是根据需要而增加的选项。如果不足4n字节,要加填充位,使得选项长度为32位(4字节)的整数倍,具体的做法是在这个字段中加入额外的零,以确保TCP头是32位(4字节)的整数倍。
不同于UDP协议,TCP协议的核心就是达到可靠传输,所以TCP协议提供了确认应答和超时重传机制来实现这一特点。
TCP将每个字节的数据都进行编号,即为序列号。发送方在发送一条数据之后,接收方会立即返回一个ACK(应答报文)。每个ACK都带有对应的确认序号, 确认序号告诉发送方,我已经收到了那些数据,并向发送方索要后面的数据。
例如:如果确认序号为1001,那么表示小于1001的数据已经接收到,接下来索要从1001开始的数据。
注意: 在实际生活中我们可能遇到后发先至的情况。
解决办法: 上述关于TCP协议的特点,包括有接收缓冲区以及发送缓冲区(一块内核中的内存空间),此时我们TCP就能通过序号针对收到的消息进行排序,此时应用程序读取的数据就是与发送顺序一致。
在实际生活中我们可能还会遇到丢包的问题,特别是我们在进行对抗性游戏并且网络质量不好时。
那么这是为什么呢?
其实是我们数据在与服务器进行传输的过程中有一部分数据丢失了。数据传输过程会经过许多节点。任何一个出问题都有可能丢包。 而传输过程中每一个设备的传输能力都是有限的,当设备转发达到峰值就有可能引起部分数据丢失。
此时就有TCP的超时重传机制来避免这个问题尽可能保证可靠传输。
超时重传机制: 如果丢包了,那么接收方就收不到数据,就不会返回ACK,那么此时发送方等待一段时间,就会重新发送一遍。当连续重传时,超时等待时间变长,多次重传无效就会尝试重置连接,如果也失效就会关闭连接。
丢包的两种情况:
注意: 此时TCP在缓冲区会帮我将接受的数据根据序号进行去重,所以上述,发送方再次重传时,就算已经接收过了也没有影响。
连接管理机制也与TCP的可靠性有一定关系,但是关系远远没有确认应答和超时重传的作用大。
TCP的连接管理 主要分为:建立连接,三次握手;断开连接,四次挥手。
三次握手的过程本质上是投石问路,确定客户端和服务器是不是都具备发送能力和接受能力。
在上述过程中: 其实是涉及到四次交互,但是由于我们ACK和SYN都是同一时机触发(都是由内核来完成),所以我们将这ACK和SYN合并在一起进行发送。这也是为什么建立连接是三次握手,断开连接是四次挥手。
四次挥手,我们通信双方各自给对方发送一个FIN(结束报文),然后各自接收到后返回一个ACK(应答报文),表示收到,然后就断开连接了。
如图所示:
解释说明: 当我们的客户端进行显示调用 close() 方法或者进程结束(隐式调用)时,就会触发FIN,此时虽然进程结束了,TCP连接还在,我们系统内核还是会将连接继续维护,直到四次挥手完成,服务器端同理。
上述过程: 由于我们的FIN是由应用程序代码控制的只有在调用socket的 close() 方法时才会触发FIN,所以此时与ACK极少可能是同一时机触发,所以就是四次挥手。
以上当我们保证可靠传输时,往往就会忽略传输的效率,所以我们的UDP传输速度远远高于TCP,所以我们TCP为了提高效率就提出了 滑动窗口 来实现批量传输。
解释说明:
我们每发送一个数据都要等待一个ACK的时间,通过批量传输数据,我们可以边等待上一个数据的ACK边发送下一个数据。一次性传输数据的多少就是我们滑动窗口的大小,每接收到一个ACK就发送一个数据,此时我们窗口大小就不会改变,视觉上就类似于滑动窗口。
批量传输中的丢包情况:
返回的ACK丢失:这种情况不会有影响,因为我们的确认序号是表示在此之前所有的数据都收到,丢失一个ACK,后一个ack也会覆盖前一个ACK的含义。如图所示:
此时: 如果是批量传输的最后一个丢失,就照常批量重传。
发送的数据丢失:如图1001-2000这个数据丢失,此时我们接收方就会反复索要1001-2000这个数据,发送方接收到几次索要1001-2000这些数据时就能发现数据丢失,此时就进行重传1001-2000这个数据。
注意: 当接收方接到该数据时返回的确认序号是7001,因为之前的数据已经接收到了,如果有多组丢失的话,此时索要的就是下一组丢失的数据。 这种重传过程也称快速重传,没有任何冗余。
注意: 以上我们的滑动窗口机制,进行批量传输,但是我们接收方的缓冲区是有限的,如果一次性传输过多也会造成数据丢失,所以我们进行批量传输时就会受到数量控制,也就是流量控制。
它也是可靠性保证的一种机制。,本质是通过接收方来限制一下发送方的速度。如图中,TCP报文格式中的16位窗口大小,该字段表示的建议发送方发送的窗口大小。当ACK=1时,表示是一个应答报文,此时窗口大小就会生效。
此处的窗口大小一般指的是接收方缓冲区剩余空间大小,ACK每次会返回一个窗口大小,接收方就根据这个窗口大小调整一次性批量传输的数据,如果窗口大小为0时,此时发送方就会停止发送,并每隔一段时间发送一个探测报文,探测接收方缓冲区是否空出,空出就继续发送。
以上根据流量控制机制指定的的窗口大小还需要根据拥塞机制来共同确定。一般取拥塞控制得到的窗口大小和流量控制的窗口大小的最小值。
拥塞控制: 拥塞控制是衡量中间节点转发数据的能力,由于中间节点个数不定以及每次传输的路径都是会有变化的。所以此时就通过实验的方式得到一个合适的窗口大小 称为拥塞窗口 。实验过程大概如下:
我们延时应答机制也是提高TCP传输效率的一种方式,当我们接收方接收到数据时,可以延时发送ACK,此时在延迟一段时间内我们接收方缓冲区就会被应用程序读取一部分数据,此时我们返回的ACK 窗口大小 就会增加,以此提高了我们的效率。
窗口越大,网络吞吐量就越大,传输效率越高,我们是在保证网络不拥堵的情况下尽量提高传输效率
注意: 不是所有的包都进行延时应答,主要是根据实际情况比如:
捎带应答机制也是提高效率的一种方式。 在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是 “一发一收” 的。意味着客户端给服务器说了 “How are you”,服务器也会给客户端回一个 “Fine, thank you”。那么这个时候ACK就可以搭顺风车,和服务器回应的 “Fine,thank you” 一起回给客户端。如图所示:
解释说明: 我们ACK是内核负责,一般会立即返回,而这些返回的应答response是由应用程序,通过write写入数据,执行到才返回。但是由于延时应答,所以我们的ACK也可能稍等一会再发送,此时就会和response合成一个数据报发送。
这也是为什么上述我解释四次挥手也有可能三次挥手的原因
我们知道TCP协议是面向字节流的,但是我们在将数据写入缓冲区时就会遇到粘包问题,我们的数据紧靠在一起,此时接收方读取时可能不能分辨那些是一条数据。
解决办法:
最后:TCP还有许多比较有利的机制,以上只是介绍了一些比较核心的。如果还想了解的话,可以参考TCP RFC标准文档。
TCP流套接字主要分为:ServerSocket API (创建服务器)和Socket API (创建客户端或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket)。
ServerSocket:
方法名 | 方法使用 |
---|---|
ServerSocket(int port) | 创建一个服务端流套接字Socket,并绑定到指定端口 |
方法名 | 方法使用 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭此套接字 |
– | – |
Socket :
方法名 | 方法使用 |
---|---|
Socket(String host,int port) | 创建一个客户端流套接字,并与对应主机上的对应端口进程建立连接 |
InetAddress getInetAddress() | 返回此套接字连接的地址 |
---|---|
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStream() | 返回次套接字的输出流 |
– | – |
TCP中的长短连接:
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接。
以下将根据提供的TCP协议版本的API实现字典翻译的服务器以及客户端。
注意:以下是创建的回显式服务器,我们的process()方法不会根据客户端的请求改变来发送响应,而是直接返回。后面我们可以通过继承改重process()方法来处理我们的请求
package 网络编程;
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;
/**
* @author zq
*/
public class TCPEchoSever {
//serverSocket只有一个,
// clientSocket会给每个客户端分配一个
private ServerSocket serverSocket = null;
public TCPEchoSever(int port ) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
//通过线程池实现连接多个客户端的情况
ExecutorService executorService = Executors.newCachedThreadPool();
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
executorService.submit(new Runnable() {
@Override
public void run() {
try {
//这个方法用来处理连接
processConnection(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}
//通过这个方法来处理一个连接
//读取请求
//根据请求计算响应
//把响应返回客户端
private void processConnection(Socket clientSocket) throws IOException {
System.out.printf("[%s:%d] 客户端上线\n",clientSocket.getInetAddress(),
clientSocket.getPort());
//try()括号允许多个流对象用;分隔
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
Scanner scanner = new Scanner(inputStream);
//printWriter也是一个字符流,一般字符流就可以传入字符串
//字节流一般输入的参数都是byte型的数或者数组
PrintWriter printWriter = new PrintWriter(outputStream);
while (true){
//1.读取请求
if (!scanner.hasNext()){
//读取的流到结尾了,对端关闭了
System.out.printf("[%s:%d] 客户端下线\n",clientSocket.getInetAddress(),
clientSocket.getPort());
break;
}
//直接使用scanner读取一段字符串
String request = scanner.next();
//Scanner相当于字符流
//2.根据请求计算响应
String response = process(request);
//3.把响应返回客户端,注意响应里面也有加上换行所以
//用println而不是write
printWriter.println(response);
printWriter.flush();
System.out.printf("[%s:%d] req: %s;resp: %s\n",clientSocket.getInetAddress(),
clientSocket.getPort(),request,response);
}
} catch (IOException e) {
e.printStackTrace();
}finally {
//注意此时与前面不一样,这个clientSocket是频繁创建
// 释放的所以要关闭
// 就需要关闭资源
clientSocket.close();
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TCPEchoSever tcpEchoSever = new TCPEchoSever(9090);
tcpEchoSever.start();
}
}
以下是继承重写后实现字典翻译的服务器:
package 网络编程;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* @author zq
*/
public class TCPDictServer extends TCPEchoSever{
//new一个HashMap,用于实现英译汉
private HashMap<String,String> map = new HashMap<>();
public TCPDictServer(int port ) throws IOException {
super(port);
map.put("dog","小狗");
map.put("cat","小猫");
map.put("love","爱");
}
@Override
public String process(String request){
return map.getOrDefault(request,"该单词没有查到");
}
public static void main(String[] args) throws IOException {
TCPDictServer tcpDictServer = new TCPDictServer(9090);
tcpDictServer.start();
}
}
package 网络编程;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
/**
* @author zq
*/
public class TCPEchoClient {
private Socket socket= null;
public TCPEchoClient(String serverIp,int port) throws IOException {
//让客户端与服务器的建立TCP连接,
// 没有连接上accept会阻塞,连接上了就会返回
socket = new Socket(serverIp,port);
}
public void start(){
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
//outputStream写
PrintWriter printWriter = new PrintWriter(outputStream);
//inputStream读取响应
Scanner scannerFromSocket = new Scanner(inputStream);
while (true){
//1.从键盘读取用户输入
System.out.println("->");
String request = scanner.next();
//2.把读取的内容构造成请求,发送给服务器
printWriter.println(request);
printWriter.flush();//手动刷新
//3.从服务器读取响应内容
String response = scannerFromSocket.next();
//4.把响应结果显示到控制台
System.out.printf("req:%s;resp:%s\n",request,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();
}
}