前言
在上一篇关于网络协议的博客中,我们简单概括了网络套接字中的UDP协议,本篇博客我们将继续学习分享关于网络套接字中另一个协议,TCP网络协议
一、UDP和TCP协议区别是什么?
二者之间的区别如下
🔗UDP的主要特点
无连接:使用UDP的通信双方不需要直接保存对方的信息,直接投递。例如发短信,电信诈骗分子发短信给我,但是我压根不认识这些坏人,但是可以直接发短信给我
面向数据报:UDP是以一个数据报作为传输单位,不可能只传输半个数据报。
不可靠传输:不可靠传输不是意味着不安全,而是表示只要双方其中一个发送了数据即可,不关心有没有收到。
全双工:一条路径,双向通信。
🔗TCP的主要特点
有连接:跟UDP不同,使用TCP的双方必须要建立联系。我打电话约朋友出来打球,大家拿起电话通话建立了联系,才知道约的是几点打球。
面向字节流:以字节作为传输的基本单位,读写都是非常灵活的。
可靠传输:发送数据不能保证100%传输,但是需要尽可能的传输过去。
全双工:一条路径,双向通信。
🧷注:此处说的连接不是指拿绳子把通信双方绑在一起,举个例子。一对新人去民政局领证,结婚证一式两份,盖完章,此时新娘和新郎官就建立了联系。
既然有全双工,就会有半双工📥
二、TCP套接字网络编程
1.API
TCP网络编程也是基于Socket套接字开发,双发如果需要通信必须要建立连接,在网络编程中收发是以字节为单位的流式传输。
对于TCP网络编程,Java提供了2个类进行传输,分别是针对服务器和客户端。ServerSocket API,是创建TCP服务器Socket的API,Socket API是基于客户端的API。
ServerSocket构造方法
方法签名 | 方法签名 |
---|---|
ServerSocket(int port) | 创建一个服务器端流套接字Socket,并绑定到指定端口 |
这个Socket 和 UDP中的DatagramSocket 类似,都是构造的时候指定一个具体放入端口
让服务器绑定该端口,但是此处的ServerSocket一定是绑定具体的端口。
ServerSocket关键方法
方法签名 | 方法说明 |
---|---|
Socket accept() | 开始监听指定端口(创建时绑定的端口),有客户端连接之后,返回一个服务器Socket对象,并基于这个Socket建立与客户端的连接,否则阻塞等待 |
void close() | 关闭套接字 |
accept的意思是接收,服务器是被动的一方,客户端则是主动的一方,客户端主动向服务器发起连接请求,服务器需要同意一下,accept和上述字面意思是不一样的,TCP连接的接收是在内核里面已经完成了,此处的accept是应用层层面的接受。
Socket API
Socket是客户端Socket,也是服务端中收到客户端建立连接(accept方法)的请求之后,返回服务端Socket。
Socket构造方法
方法签名 | 方法说明 |
---|---|
Socket (String host, int port) | 创建一个客户端流套接字Socket,并与对应IP主机上,对应的端口建立连接 |
String host 和 int port 分别代表服务器的IP和端口;
TCP是有连接的,客户端在new Socket的时候,就会尝试和指定IP端口的目标建立连接了。
Socket方法
方法签名 | 方法说明 |
---|---|
InetAddress getInetAddress() | 返回套接字所连接的地址 |
InputStream getInputStream() | 返回此套接字的输入流 |
OutputStream getOutputStram() | 返回此套接字的输出流 |
InputStream 这里读取数据,就相当于从网卡接收
OutputStream 这里写数据,就相当于从网卡发送
Socket就相当于是一个遥控器🕹️ ,在上一篇网络编程一的博客中我们有详细的说明。
2.长短连接
我们介绍了关于TCP网络编程开发中所用到的API,知道了如何使用这些方法,在发送数据的实收需要建立联系,何时关闭就是由长短连接来决定。
短连接:每一次收到数据后并返回响应,直接关闭连接,短连接只能一次性收发数据。
长连接:一直保持连接状态,双方不停的收发数据,可以多次收发数据。
二者之间也是由明显的区别:
- 使用场景不同,短连接适用于客户端请求次数不高的业务,例如打开浏览器网页,可以多次打开并且响应快。长连接则适用于长时间的请求和响应,例如游戏和网上聊天,是实时的互动并且属于高频率使用。
- 发送请求不同:短连接一般是客户端主动向服务端发送请求,长连接则是客户端和服务端都可以互相发送。
- 耗时不同:短连接每次收发数据都需要建立并关闭连接,而长连接只需要建立一次连接,后续的请求和响应都可以直接传输,从建立连接的角度出发,长连接每次建立连接只需要一次,耗时短且执行效率很高
3.TCP回显服服务器程序
3.1 TCP服务器代码
设计一个TCP服务器端的步骤如下所述
1.创建ServerSocket服务器端实例对象,并在服务器上指定端口号
2.启动服务器,使用accept方法与客户端建立连接,如果客户端没有建立连接,accept会阻塞。
3.接收客户端的请求
4.处理客户端发送的请求,计算响应
5.将响应结果返回给客户端
代码视图📃
Part1
public class TcpEchoServer{
private SerberSocker serverSocket = null;//创建一个服务器专用的socket,用于和客户端建立连接
public TcpEchoServer(int port) throws IOException {//设置端口号
serverSocket = new ServerSocket(port);//把端口号传递给服务器
}
}
Part2
public void start() throws IOException {
System.out.println("服务器启动");
while(true){// 死循环,和UDP没有区别,服务器需要随时接收请求
Socket clientsocket = serverSocket.accept();//和客户端建立连接;clientSocket
processConnection(clientsocket);//处理连接的方法,稍后解释
}
}
处理连接的方法📥
Part3
private void processConnection(Socket clientSocket) throws IOException{ //长连接处理
System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress().toString),
clientSocket.getPort());
//基于clientSocket对象和客户端进行通信
try(InputStream inputStream = clinetSocket.getInputStream();//写
OutputStream outputStream = clienttSocket.getOutputStream()){//读
Scanner scanner = new Scanner(inputStream);
PrintWriter printWriter = new PrintWriter(outputStream);
//考虑到客户端会一直发送请求,所以用while循环
while(true){
//1.读取请求
if(!scanner.hasNext())
System.out.printf("[%s:%d] 客户端下线\n",
clientSocket.getInetAddress().toString(),
clientSocket.getPort());
break;
}
//直接使用scanner 读取一段字符串,next 是一直读取到换行符号/空格/其他空白字符
//但是最终返回结果是不包含空白符的
String request = scanner.next();
//2.根据请求响应计算
String response = process(request);
//3.把响应写回客户端
printWriter.println(response);
printWriter.flush();
System.out.printf("[%s:%d] req:%s; resp: %s\n",
clientSocket.getInetAddress().toString(),
clientSocket.getPort(), request, rsponse);
}
} catch (IOException e){
e.printStackTrace();
} finally{
clinetSocket.close();
}
}
private String process (String requset){
return request;
//回显程序,写的是啥,读取的就是啥
}
public static void main(String[] args) throws IOException {
TcpRchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start();
}
}
以上的代码中部分内容较为繁琐,我们逐个说明
问题1:已经有了一个serverSocket,为什么还需要一个clientSocket,需要这么多的Socket吗?
答:举个例子,以卖房和买房作为理解场景。我们假设serverSocket是卖房子的销售小哥,负责招揽顾客去售楼大厅看房,顾客到了售楼大厅看房子,就会为这些顾客安排一位负责讲解楼房的小姐,此时售楼小姐就是clientSocket,销售小哥转身又去拉顾客来看房子。周而复始,只要卖房子的销售小哥(serverSocket)拉进来一名顾客,都会为顾客分配一名售楼小姐(clientSocket)讲解。
通过accept将服务端和客户端建立连接以后,accept所返回的socket,我们就将他命名为clientSocket。
所以理解起来如下
serverSocket 就是在外面招揽客人的售楼小哥
clientSocket 就是在大厅内部的服务的小姐
serverSocket只有一个,但是clientSocket 会给每个客户端都分配一个
问题2:处理一个客户端连接的过程中,是发来一个请求就结束了,还是会发送来多个请求,如何处理多个响应?
答:我们在处理多个响应的过程中,用到try的写法
在 () 中允许写多个对象,使用 ;进行分割,这样无论是短连接还是长连接我们都可以获取到。
问题3:既然有了读和写,如果是一个长连接,那么读到哪里才算是一个完成的请求呢?
答:首先我们需要理解两个事项,第一,每个请求都是一个字符串(文本数据)。其二,请求和请求之间使用 \n 来进行分割,TCP是面向字节流的,像水流一样的东西,水龙头一旦打开就会放水,接了多少水算是结束呢?只要看到 \n 就会把水龙头关闭。
为了方便读取输入流(inputStream)和输出流(outputStream),我们用到了Scanner 和 PrintWriter,不用行不行呢?完全可以,但是代价就是一个字节一个字节去扣,我们通过Scanner 和 PrintWriter将字节流包装成了字符流,上述约定了请求是字符串,此时也可以通过字符流处理,本体还是inputStream 和 outputStream,Scanner 和 PrintWriter只是套壳而已。
问题4:hashNext()的作用
当进入while()循环后,此时客户端会一直向服务端发送请求,通过hashNext()方法判断输入(文件、字符串等一系列的输入流)是否还有下一个输入项,如果有返回true,反之false。hashNext()会等待客户端的输入,也会阻塞等待输入源的输入,当客户端关闭了连接以后吗,输入源也就结束了,没有下一个数据了,已经读完了,此时hashNext()就为false了。
紧接着scanner读取一段字符串,next()会一直读取到空白符结束,空格,换行,制表符,翻页符都算是空白符,但是返回的结果中是不会有空格或者空白符的。
问题5:为什么clientSocket需要被关闭,而serverSocket则不需要关闭?
答:serverSocket是一直在用。clientSocket只是在循环当中,每次客户端连接,accept都会返回一个Socket,有十万个客户端就会有十万个Socket,不用不回收,就会造成文件资源泄露,线程使用的文件是有上限的。
3.2 TCP客户端代码
设计一个TCP客户端代码步骤如下
1.从键盘上读取用户输入的内容
2.把读取到的内容构造成请求,发送给服务器
3.服务器读取响应内容
4.把响应结果显示到控制台上
代码视图📃
Part1
public class TcpEchoClient{
private Socket socket = null;
public TcpEchoClient(String serverIp, int port) throws IOException {
//此时通过服务器端口绑定的端口,我们已经将客户端和服务端绑定在一起了
//这里的连接连上了,服务器的accept就会返回一个socket
socket = new Socket(serverIp, port);
}
}
Part2
public void start() throws IOException {
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStram = socket.getOutputStream()){
PrintWriter printWriter = new PrintWriter(outputStream);
Scanner scannerFromSocket = new Scanner(inputStream);
while (true){
//1.从键盘上读取用户输入的内容
System.out.println("->");
String request = scanner.next();
//2.把读取的内容构造成请求,发送给服务器,发送过来的数据是带有换行的
//此时只能通过next去读取,发送的数据没有换行,next会一直等,等到有换行的出来
printWriter.println(request);
printWriter.flush();//flush代表将刷新
//3.服务器读取响应内容
String response = scannerFromSocket.next();
//4.把响应结果显示到控制台上
System.out.printf("req:%s;resp: %s\n",request,response);
}
}catch(IOException e){
e.printStackTrace();
}
}
Part3
public static void main(String[] args)throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",port:9090);//服务器端设置的端口号也是9090
client.start();//启动客户端
}
3.3运行结果
1.启动服务器
2.启动客户端
3.服务器返回响应
三、TCP后续问题
1.不加flush(),客户端启动后会一直无反应
将flush()方法注释之后,我们看下运行结果📥
先正常启动服务器
再启动客户端
这里光标一直停在这里,并没有返回一个一模一样的响应,这是什么原因导致的?
要理解这个问题我们需要知道,数据都是放在缓冲区中的。缓冲区(buffer)是内存空间的一部分,也就是说,在内存空间中预留了一定的存储空间,这些存储空间用来缓冲输入或者输出的数据,这部分预留的空间就被称为缓冲区。内存的读写速度很快,硬盘的读写速度很慢,为了提高IO效率。(读写硬盘和读写网卡都可以视为IO操作)引入缓冲区减少IO次数,就可以适当的提高整体的效率。假设要写十次网卡,就需要先把要写的数据放一个到内存构成的缓冲区(buffer),再统一把这个缓冲区中的数据写入网卡。
没加flush之前,只是把数据写入到内存的缓冲区中,等到缓冲区满了,才会写真正的网卡,flush相当于手动刷新缓冲区,可以让数据立即写入网卡。
2.服务器无法连接多个客户端
当我开第二个客户端的时候,服务器是没有任何回应的。
我们在服务器代码中提到了 processConnection 方法,启动服务器后,这个while循环,accept把内核建立的连接引入到程序中,只要有一个客户端,就需要accept一次,有多个客户端就需要accept多次,只有与当前通信的客户端传输完成后才会退出,也就是说这里的循环不退出,Socket对象就没有办法释放去建立其他连接,无法连接多个客户端。
这里我们可以采用多线程/线程池的方式来解决问题,让主线程负责accept和客户端建立连接,每接收一个连接,创建新的线程,由新的线程来负责处理这个新的客户请求。
修改后的代码🔨
public void start() throws IOException {
System.out.println("服务器启动");
while (true) {
Socket clientsocket = serverSocket.accept();
Thread t =new Thread(()->{
try{
processConnection(clientsocket);
}catch (IOException e) {
e.printStackTrace();
}
});
t.start();
}
}
创建新的线程,用新的线程来调用 processConnection,每次有新的客户端就搞一个新的线程即可。
此时就服务器就可以调用多个客户端了,主线程只做2件事,accept获取请求和创建线程,当线程创建好了,就会立即下一次调用accept。但是有个弊端,创建线程会多次频繁销毁会浪费内存空间,使用线程池的方案相比较创建新的线程在效率和浪费资源的上面来说是更加好的方案。
修改代码🔨
public void start() throws IOException {
ExecutorService executorService = Executors.newCachedThreadPool();
System.out.println("服务器启动");
while (true) {
Socket clientsocket = serverSocket.accept();
executorService.submit(new Runnable() {
public void run() {
try{
processConnection(clientsocket);
} catch (IOException e) {
e.printStackTrace();
}
}
});
}
}