前面一篇文章介绍了 UDP ,接下来介绍另外一组 API
TCP 的 socket API
UDP特点:无连接,不可靠,面向数据报,全双工
TCP特点:有链接,可靠性,面向字节流,全双工
全双工:双向通信,借助一个 socket 对象,既可以发送数据,也能接收数据
半双工:单向通信,要么只能读,要么只能写
TCP 的 API
TCP 的 API 也涉及到两个核心的类
- ServerSocket
- Socket
和 UDP 差别较大(差别也就体现在 UDP 和 TCP 之间的特性的区别上)
TCP 的回显服务器
UDP 协议无连接,类似于发短信【无所谓对方当时有没有收到,你只管发消息即可】
TCP 协议有连接,类似于打电话【对方接通电话你才能跟对方说话】
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
public class TcpEchoServer {
//1.初始化服务器
//2.进入主循环
// 1)先去从内核中获取到一个 TCP 的连接
// 2)处理这个 TCP 的连接
// a)读取请求并解析
// b)根据请求计算响应
// c)把响应写回到客户端
private ServerSocket serverSocket = null;
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
//1)先从内核中获取到一个 TCP 连接
Socket clientSocket = serverSocket.accept();
//2)处理这个连接
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) {
System.out.printf("[%s:%d]客户端上线\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
//通过 clientSocket来和客户端交互,先做好准备工作,获取到 clientSocket 中的流对象
try(BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))) {
//此处我们先实现一个长连接版本的服务器
//一次连接的处理过程中,需要处理多个请求和响应
//这个循环在客户端断开连接时就结束了
//客户端断开连接时,服务器再去调用 readLine 或者write 方法都会触发异常
while (true){
//1.读取请求并解析(此处的 readLine 对应客户端发送数据的格式,必须按行发送)
String request = bufferedReader.readLine();
//2.根据请求计算响应
String response = process(request);
//3.把响应写回客户端(客户端需要按行来读)
bufferedWriter.write(response + "\n");
System.out.printf("[%s,%d]req:%s; resp:%s\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
}
} catch (IOException e) {
//e.printStackTrace();
System.out.printf("[%s:%d]客户端下线\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
TCP 的连接管理是由操作系统内核来管理的,客户端和服务器建立连接过程,完全由内核来进行负责,应用程序代码感知不到,当连接建立成功,内核已经把这个连接对象放到阻塞队列中了。代码中调用的 accept 就是从阻塞队列汇总取出一个连接对象(在应用程序中的化身就是 Socket 对象)
这就是一个生产者消费模型,后续的数据读写就是针对 clientSocket 这个对象来进行展开的
如果服务器启动之后,没有客户端建立连接,此时代码中调用 accept 就会阻塞,阻塞到真的有客户端建立连接之后为止。
管理:先描述,再组织【描述->通信中的五元组:协议类型、源IP 、源端口、目的 IP、目的端口;组织->使用一个阻塞队列来组织若干个连接对象】
理解 clientSocket 和 serverSocket:
- clientSocket:与客户端进行交互
- serverSocket:处理客户端的连接
处理链接
一个连接中,客户端和服务器就只交互一次吗?
服务器的处理方式:
- 一个连接中,客户端和服务器之间只交互一次,交互完毕,就断开连接——短连接
- 一个连接中,客户端和服务器之间交互 N 次,直到满足一定条件再断开连接——长连接【效率更高,避免了反复建立连接和断开连接的过程】
理解下面的代码:
服务器读取请求的代码
此处暗含一个重要信息:客户端发的数据必须是一个按行发送的数据(每一条数据占一行),相当于是应用层的一种自定义协议,发送方和接收方都要遵守,上面的代码时服务器(接收方)的逻辑,意味着为了让程序能够很好的跑起来,客户端也需要按行发送数据
TCP 的客户端
import java.io.*;
import java.net.Socket;
import java.util.Scanner;
public class TcpEchoClient {
//1.启动客户端(一定不要绑定端口号)和服务器建立连接
//2.进入主循环
// a)读取用户输入的内容
// b)构造一个请求发送给服务器
// c)读取服务器的相应数据
// d)把响应数据显示到界面上
private Socket socket = null;
public TcpEchoClient(String serverIp,int serverPort) throws IOException {
//此处的实例化 Socket 过程,就是在建立 TCP 连接
socket = new Socket(serverIp,serverPort);
}
public void start(){
System.out.println("客户端启动");
Scanner scanner = new Scanner(System.in);
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream()))){
while (true){
//1.读取用户输入内容
System.out.println("->");
String request = scanner.nextLine();
if (request.equals("exit")){
break;
}
//2.构造请求并发送,此处 + \n 为了和服务器中的 readLine 相对应
bufferedWriter.write(request + "\n");
//3.读取响应数据
String response = bufferedReader.readLine();
//4.把响应数据显示到界面上
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
客户端发送请求的代码
此处的“按行写”和“按行读”这样的过程就是一种最简单的自定制协议,完全可以把这个协议约定成其他方式。
客户读取响应数据,与服务器写会响应对应
也是自定制协议,自定制协议的时候,既需要考虑到请求,也需要考虑到响应
整体梳理执行流程
服务器
1、服务器启动
2、进入主循环
1)从内核中获取一个连接
当客户端没有连接过来的时候,accept 就会阻塞
客户端
1、客户端启动
这个操作完成就会和服务器建立连接
【等待客户端建立连接……】
客户端连接建立成功,服务器就从 accept 返回
2)处理一次连接
此处是长连接,下面的操作都是在循环中完成的
a)读取请求
此时此刻客户端只是建立连接了,但是还没有发送请求,此处的 readLine
客户端
2、进入主循环
a)读取用户输入的数据
此处的读取操作也会阻塞。(用户不一定立刻就输入内容),一直阻塞到用户真的输入了数据为止
【阻塞等待用户输入数据……】
b)用户输入数据之后,就会发送请求
此处的 write 发送成功之后,服务器就会从 readLine 中返回
【等待客户端发送请求……】
服务器
b)根据请求计算响应
由于是 echo server ,请求是什么,响应就是什么
c)把响应写回给客户端
客户端
c)从服务器读取响应
在服务器返回数据之前,此处的 readLine 也会阻塞
【阻塞等待服务器返回响应】
d)当服务器把响应写回之后,客户端才能从上面的 readLine 返回,继续执行后续的打印操作
这里的 readLine 和 wirte 就相当于 UDP 中的receive 和 send,UDP 中必须用 receive 和 send,因为传输数据的基本单位是DatagramPacket 对象【面向数据报】;TCP 中,必须用 read 和 write ,传输数据的基本单位是字节【面向字节流】。这是传输层协议上的差异导致的代码差异。
当前我们写的客户端服务器中存在两个致命 bug
运行一下程序:
启动服务器后再启动客户端,在客户端输入一串字符:
服务器这边没有显示输入的字符,只是显示了客户端上线
第一个 BUG:
客户端连接上之后发现服务器能感知到客户端建立连接,但是客户端发送数据的时候,服务器没有任何反馈
【分析现象,结合代码,做出假设】
当程序出现问题的时候,需要先缩小问题的范围,局限在某几行代码上,可以借助一些工具,或一些日志来分析程序的执行过程,按理说客户端发送数据给服务器之后,服务器就要作出响应,此时说明大概率的情况是:
- 客户端没有发送请求成功
- 服务器没有接受请求成功
分析:是否是服务器写会响应失败呢?(可能性不大)
原因:如果是服务器响应反悔失败,服务器这里应该会打印出一个日志的:
【验证假设】
- 借助 jconsole 就能看到当前两个进程的情况
此处我们发现服务器的代码阻塞在 readLine,【这个现象大概率说明,服务器没有收到任何数据】,怀疑对象主要就是客户端的问题
对应到代码就是下面部分
根据上面红色部分我们可以看出,客户端在试图读取数据,但是没有读取到,所以就堵塞了,所以我们基本就可以确定代码问题出在了第二步——构造请求并发送这一部分,write 操作明明执行了,但是服务器没有收到任何数据(bug 应该就这这里导致的)
原因:使用的是 BufferedWriter,write 的时候写入内存的缓冲区,并没有真正的写入 socket 文件夹中
解决方案:手动刷新缓冲区,flush 方法
在客户端和服务器都添加一条代码
重新启动客户端服务器
【客户端】
【服务器】
理解一下缓冲区
一次网 IO设备中写 1 个字节,分 100 次写的效率远远低于一次网 IO设备中写 100 个字节分一次写完的效率,这个效率近似就是差 100 倍。操作 IO 设备的程序效率,很大程度上取决于程序真正访问 IO 设备的次数,缓冲区存在的意义,就是为了减少访问 IO设备的次数
getOutputStream 得到一个流对象,进一步的包装成了一个 BufferedWrite,在代码中调用 bufferedWriter.write 方法的时候,把数据先放到缓冲区中,此时的 weite 操作并没有真的往内核中的 socket 文件中写数据,所以看到的现象就是,光写一个 write,其实客户端什么数据都没有发送,调用 flush() 方法,这就是把内存缓冲区中的内容“写入”到 Socket 文件中(网卡的代言人),才真的通过网卡来发送数据
例如:
日常开发中,有的时候,需要打印一些日志,System.out.println (里面也是带缓冲区的),如果你的代码中发现,预期的日志没出现,不一定是打印操作没执行,而是数据在缓冲区里面,没有看到日志而已,所以使用日志的时候,也要注意加上刷新缓冲区的操作,防止缓冲区对你的判断造成影响
System.out.println :
- 如果是直接打印到终端(控制台上),此时遇到换行就会自动刷新(行缓冲)
- 如果是打印到文件中,此时遇到换行也不会刷新,需要显示进行刷新(全缓冲)
在命令行中启动多个客户端
先打开一个指定目录,编译生成的 .class 目录
此时会弹出一个文件夹,该文件夹显示的目录就是我们要寻找的目录,双击进入该项目
在这个界面操作 shift + 鼠标右键,选择在此处打开 powershell 窗口,并且在窗口中操作下面的命令(类名前面的是包名称)
第二个 bug
开启两个服务器,一个在idea中启动,一个在 powershell 中启动
问题现象:
我们在第一个客户端发送数据可以得到响应
但是在 poewrshell 中的第二个客户端中却没有响应
当我们关闭第一个客户端后:
显示一个客户端下线,又显示另一个客户端上线了
此时第二个客户端又收到响应了
说明现在的服务器同一时刻只能处理一个客户端连接(同时过来多个客户端,此时只有第一个能被正确处理,第一个客户端推出了额,第二个才能被正确处理)
原因:
在于 accept的秘密~
accept 的工作原理
初始情况下,没有客户端过来连接,此时队列为空,accept 就会阻塞
当有一个客户端过来的时候,队列中有元素了(内核自动加入队列里),accept 就能取到数据
只要第一个客户端不退出,就无法结束 processConnection 中 while 循环,进而也就无法再次调用到 accept ,此时再有新的额客户端建立连接成功,连接就在队列中了,但是由于没有调用 accept,此时这个连接就没有代码去负责处理
accept 调用的速度太慢了(频率太低了),调用几次 accept 就处理几个客户端的连接
解决方案:
要在代码中,同时调用 accept 和 processConnection(让这两个操作并发执行)
并发——多线程。用一个线程专门负责调用 accept 在搞一些其他线程,每个线程专门负责一个客户端的长连接
改进前的代码:
public void start() throws IOException {
System.out.println("服务器启动!");
while (true){
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
原来的代码 accept 和 processConnection 是串行执行的
改进后的代码:
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(){
@Override
public void run(){
processConnection(clientSocket);
}
};
t.start();
}
}
accept 和 processConnection 还是并发执行的,此处代码中的 while 就会反复快速的调用 accept,于是就能同时处理多个客户端的连接
下面是多线程服务器的代码
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
/**
* Create with IntelliJ IDEA
* Description:TCP 的多线程服务器
* User:Zyt
* Date:2020-07-08
*/
public class TcpThreadEchoServer {
private ServerSocket serverSocket = null;
public TcpThreadEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动");
while (true){
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(){
@Override
public void run(){
processConnection(clientSocket);
}
};
t.start();
}
}
public void processConnection(Socket clientSocket){
System.out.printf("[%s:%d]客户端上线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
try (BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(clientSocket.getOutputStream()))){
while (true){
String request = bufferedReader.readLine();
String response = process(request);
bufferedWriter.write(response + "\n");
bufferedWriter.flush();
System.out.printf("[%s:%d]req:%s,resp:%s\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort(),request,response);
}
} catch (IOException e) {
//e.printStackTrace();
System.out.printf("[%s:%d]客户端下线!\n",clientSocket.getInetAddress().toString(),
clientSocket.getPort());
}
}
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpThreadEchoServer tcpThreadEchoServer = new TcpThreadEchoServer(9090);
tcpThreadEchoServer.start();
}
}
开启多线程服务器和客户端
再开启另一个客户端,成功返回响应,此时可以同时访问服务器了
如果一个服务器同时只能给一个客户端服务,就太失败了,这样就对这个服务器进行了改进,但是还有问题
每次来一个客户端,都要分配一个线程,对于一个服务器来说,随时可能会来大量客户端,随时也会有大量的客户端断开连接,服务器需要频繁的创建和销毁线程
就可以用线程池来解决
工厂模式:
public void start() throws IOException {
System.out.println("服务器启动");
//先创建以个线程池实例
ExecutorService executorService = Executors.newCachedThreadPool();
while (true){
Socket clientSocket = serverSocket.accept();
executorService.execute(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
节省了频繁创建和销毁线程的开销