目录
那么问题来了,为什么我们前面写的有关UDP版本的程序没有关闭呢?
一、TCP的套接字API和UDP是完全不同的
ServerSocket,是给服务器使用的
它里面有一个accept方法,这个方法和TCP“有连接”这样的特性密切相关
举个例子:
现在A(客户端)给B(服务器)打电话,A先拨号,会发出“嘟嘟嘟”的提示音,紧接着B这边就会响铃,只有B这边接通电话,A和B才能通信。
而accept就是接电话这个动作
注意
由于socket是对应到文件的,所以也会有一个close方法
我们在之前写的博客中提到过,打开一个文件要及时关闭,如果不及时关闭的话,就有可能造成文件资源的泄露,最终导致后面要打文件的话就打不开了。
那么问题来了,为什么我们前面写的有关UDP版本的程序没有关闭呢?
原因是:
一个socket理论上用完了之后是要关闭的,但是我们前面写的有关UDP版本的程序里面的UdpServer和UdpClient里面的socket生命周期,都是要跟随整个程序的。
注意
这里还有要注意的一点是即使是socket或者是文件没有关闭,但是只要进程一结束,对应的资源自然而然也就释放了。这里的资源指的是文件描述符,文件描述符在文件描述符表里,文件描述符表在PCB里,如果进程销毁了,对应的PCB也就没了。
Socket,给客户端使用的
TCP socket是面向字节流的,因此在进行读写数据的时候,和我们之前读写文件是类似的,也是使用InputStream和OutputStream。
二、使用TCP socket写一个回显程序
即写一个回显服务器和一个回显客户端,实现的功能和前面写的一样,只不过前面利用的是UDP socket,而我们这次则是使用TCP socket来实现。
服务器代码
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;
public class TcpEchoServer {
private ServerSocket listenSocket = null;
//为什么起名为listenSocket呢?
//原因是在操作系统原生的socket API中(操作系统中提供的一组C语言风格的API)其中有一个API叫做listen
//listen的这个API的功能可以让当前的socket变成一个处理连接的socket(把这个普通的socket变成了一个listenSocket)
//但是在java标准库中,listen方法已经被封装到ServerSocket内部了,我们已经感知不到了
public TcpEchoServer(int port) throws IOException {
listenSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("服务器启动!");
while (true) {
//UDP的服务器进入主循环,就直接尝试receive读取请求了
//但是TCP是有连接的,要先建立好连接
//当服务器运行的时候,当前是否有客户端来建立连接是不确定的
//如果客户端没有建立连接,accept就会阻塞等待
//如果有客户端建立连接了,此时accept就会返回一个Socket对象
Socket clientSocket = listenSocket.accept();
//服务器和客户端之间进一步的交互,就交给clientSocket来完成了
//那么怎么理解listenSocket和clientSocket分别起的作用呢?
//我们还是通过一个例子来理解
//假设我们准备买房子,而在大街上正好看到一个正在介绍楼盘的小哥
//小哥在知道我的用意后便开车把我拉到了楼盘处,并找到一个售楼小姐专门给我介绍房子的情况
//这位小哥把我介绍给售楼小姐后就走了,不管我了
//此后我买房子的所有情况都由这位小姐负责
//此刻这位小哥就好比是listenSocket,而这位售楼小姐就好比clientSocket
//如果客户端和服务器断开连接了,接下来就会销毁clientSocket
//我们的服务器中有一个listenSocket,而clientSocket可以没有,可以有一个,也可以有多个
//一个客户端只对应着一个clientSocket
processConnection(clientSocket);
}
}
private void processConnection(Socket clientSocket) throws IOException {
//处理一个连接,在这个连接中可能会涉及客户端和服务器之间的多次交互
String log = String.format("[%s:%d] 客户端上线",
clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
try (InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
while (true) {
//1.读取请求并解析
// 可以直接通过inputStream的read把数据读到一个byte[],然后再转成一个String
// 这样做太麻烦了,这里可以通过Scanner来完成这个工作
Scanner scanner = new Scanner(inputStream);
//那么什么时候循环结束呢?可以这么做
if (!scanner.hasNext()) {
log = String.format("[%s:%d],客户端下线!",
clientSocket.getInetAddress().toString(),
clientSocket.getPort());
System.out.println(log);
break;
}
String request = scanner.next();
//2.根据请求计算响应
String response = process(request);
//3.把响应写回客户端
PrintWriter writer = new PrintWriter(outputStream);
writer.print(response);
log = String.format("[%s:%d],req: %s,rep: %s",
clientSocket.getInetAddress().toString(),
clientSocket.getPort(),
request,response);
System.out.println(log);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
//当前的clientSocket生命周期不是跟随整个程序,而是和连接相关
//因此需要每个连接结束都进行关闭
//如果不关闭,那么随着连接的增多,socket文件可能会出现资源泄露的情况
clientSocket.close();
}
}
//因为当前是实现一个回显服务器
//这就意味客户端发啥,服务器就回应啥
private String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoServer server = new TcpEchoServer(9090);
server.start();
}
}
客户端代码
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;
private String serverIp;
private int serverPort;
public TcpEchoClient (String serverIp, int serverPort) throws IOException {
this.serverIp = serverIp;
this.serverPort = serverPort;
//让socket创建的同时,就和服务器尝试建立连接
this.socket = new Socket(serverIp,serverPort);
}
public void start() {
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while (true) {
//1.从键盘上读取用户输入的内容
System.out.println("->");
String request = scanner.next();
if (request.equals("exit")) {
System.out.println("goodbye");
break;
}
//2.把这个读取的内容构造成请求,发送给服务器
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.print(request);
//3.从服务器读取响应并解析
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
//4.把结果显示在界面上
String log = String.format("req:%s;resp:%s",request,response);
System.out.println(log);
}
} catch (Exception e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
tcpEchoClient.start();
}
}
客户端和服务器程序,一般都是先启动服务器,后启动客户端,这里的服务器就好比餐馆,客户端就好比要吃饭的我们,只有餐馆开门了,我们才能进去吃。
写完代码后,先运行服务器,然后运行客户端,得到的界面是
服务器和客户端的具体执行流程
说的不是很准确,就是为了看的时候有个大概的思路
现在我们梳理一遍服务器和客户端它们的交互过程究竟是怎么样的,我们一步步走一遍
梳理清楚后,我们开始往客户端输入数据,得到的结果是
对出现问题的具体解决措施
此时我们发现我们在客户端发送的数据,在服务器这边没有反应,而且服务器这边也没有把响应发送回客户端
此时我们对问题的猜测是
要么客户端没有正确发送数据
要么服务器没有正确读取数据
在服务器、客户端程序中,我们经过检查发现没有问题,因此推断应该是输入输出格式的问题
我们把客户端程序画圈这里的print改为println
然后再次输入hello
得到的结果和原来一模一样
我们继续分析,这次通过添加打印的方式来检查问题
首先我们在客户端的程序中添加打印
运行程序得到的结果是
结果仍然不对
接下来我们在服务器中添加打印
运行程序得到的结果是
结果仍然错误,不过我们通过两次添加打印,我们知道了服务器在这里阻塞着
可能的原因是
1.客户端没有真正发送出数据
2.客户端这边数据发送出去了,但服务器这边因为数据格式的问题(比如空白符导致scanner.next()不能返回)
那么到底是什么原因呢
我们可以通过抓包工具来分析。
那么什么是抓包工具呢?
我们这里拓展一下,所谓的抓包工具就是一种特殊的工具可以监测到网卡上的数据是怎么走的,客户端有没有发出数据,用抓包工具一看就能看出来。如果客户端真的发了数据了,那么在抓包工具中是能抓到这个数据的。如果客户端没有真的发数据,那么抓包工具是抓不到的。
TCP的抓包工具主要有两个:tcpdump(linux的抓包工具),wireshark(是一个跨平台、图形化的抓包工具)
可以在应用市场里面直接下wireshark,下完后会显示这么一个页面
这些是当前机器上的“网络接口”,是网卡或者是虚拟网卡,我们要进行选择
如果数据走的是有线网,那么就选以太网(当然,我们这个页面没有,应该是真的有线网的话才有这个选项)
如果数据走的是无线网,那么就选WLAN
如果数据走的是127.0.0.1环回ip,就选Adapter for loopback
当前我们是用的是环回ip,因此我们选Adapter for loopback,选完之后,我们需要设置一个过滤器,抓取我们想要的数据,最简单的办法是按照端口号来过滤,即只要端口号为9090的数据。
我们先分析这个页面
这个图上的数据代表的是端口号为61107的进程和端口号为61106的进程之间的数据交流。
另外我的wireshark打开选中Adapter for loopback之后,是这个页面,
然后我们先后打开服务器程序和客户端程序,然后再观察wireshark的变化
这么多的数据包该怎么办呢,此时我们可以在这里先写tcp.port==9090,然后回车
我们发现只剩三行了,而这三行正是tcp三次握手的过程
61439是客户端的端口号,9090是服务器的端口号
接下来我们在客户端的输入框中输入hello,此时没任何反应
看wireshark也没有反应,没有新的数据包出现
通过抓包,我们知道了是客户端没发送成功的问题
后面经过研究发现,我们没有发送成功的原因有两点:
1.进行发送的时候,没有加ln,此时,next很可能是无法直接返回的
2.发送的时候加flush刷新缓冲区,否则数据并没有真的通过网卡来发送
在经过上述的修改后,我们发现服务器显示正常了,但是客户端显示仍旧那样,又经过像修改客户端的代码一样修改服务器的代码后,客户端这边也正常了。
修改的代码是
客户端
服务器
经过修改后的代码运行结果为
修改完代码后,输入hello,回车,我们发现显示正常了,也就是服务器和客户端都有反应了
我们再观察wireshark,发现不仅有了三次握手的过程,还有了新的数据包
在后面的学习中,我们用的wireshark不多,用的另一个抓包工具会很多,它叫做fiddler,是专门抓http协议的数据包的。
对阻塞的认识
在网络通信程序中,涉及到很多的“阻塞”这样的情况,这些阻塞,其实是客户端和服务器之间流程的交替。
另外,我们前面也学过多线程、锁也会导致阻塞,一旦阻塞,就意味着线程要被挂起(放到等待队列中,等时机成熟了,才能被唤醒,然后进一步的往下执行,而等待和唤醒则取决于系统的调度),一旦出现阻塞,那么整个程序的效率就会收到很大的影响。
在进行IO处理中,很多时候的确会产生阻塞(阻塞等待的过程是无法避免的),因为服务器是不知道客户端什么时候发送请求的。
那么在实际的开发中,要提高这种程序的效率,该如何做?
答案是没有办法解决阻塞等待,但是可以在程序阻塞等待的过程中干点其它的事。
三、总结
套接字~在TCP版本的套接字API中
ServerSocket:服务器这边使用的
Socket:客户端和服务器都需要使用
对服务器来说:
1.创建ServerSocket关联上一个端口号,称为listenSocket(作用是在场外拉客)
2.调用ServerSocket的accept方法
accept的功能是把一个内核建立好的连接拿到代码中处理
accept会返回一个Socket实例,称为clientSocket(在内场负责给客户提供具体的服务)
3.使用clientSocket的getInputStream和getOutputStream得到字节流对象,然后就可以进行读取和写入了。
4.当客户端断开连接之后,服务器要及时地关闭clientSocket,否则可能会出现文件资源泄露的情况。
对客户端来说:
1.创建一个Socket对象,创建的同时指定服务器的ip和端口(这个操作会让客户端和服务器建立TCP连接,连接的过程为“三次握手”,这个流程是内核完成的,我们感知不到)
2.客户端通过Socket对象的getInputStream和getOutputStream和服务器进行通信。