文章目录
- 1. TCP 流套接字
- 1.1 ServerSocket API
- 1.2 Socket API
- 1.3 TCP中的长短连接
- 2. TCP 版本的回显服务器
- 3. TCP 版本的回显客户端
- 4. 如何给多个客户端提供服务
1. TCP 流套接字
TCP 不需要一个类;来表示 “TCP” 数据报。
TCP 不是以数据报为单位进行传输的,而是以字节流的方式进行传输的。
1.1 ServerSocket API
ServerSocket 是专门给服务器使用的 Socket 对象。
ServerSocket 构造方法:
- ServerSocket(int port) :创建一个服务端流套接字Socket,并绑定到指定端口
ServerSocket 方法:
1.2 Socket API
Socket 是既会给客户端使用,也会给服务器使用。
不管是客户端还是服务端 Socket,都是双方建立连接以后,保存的对端信息,即用来与对方收发数据的。
Socket 构造方法:
Socket 方法:
- InetAddress getInetAddress():返回套接字所连接的地址
- InputStream getInputStream():返回此套接字的输入流
- OutputStream getOutputStream():返回此套接字的输出流
进一步通过 socket 对象,获取到内部的 流对象,借助流对象来进行发送/接收。
在服务器这边,是由 accept 返回的。
在客户端这边,是由代码构造的。构造的时候指定一个 ip 和 端口号。(此处指定的是服务器的 ip 和 端口)
1.3 TCP中的长短连接
TCP发送数据时,需要先建立连接,什么时候关闭连接就决定是短连接还是长连接:
短连接: 每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接: 不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
区别:
- 建立与关闭的耗时:短连接每次请求、响应都需要建立连接,关闭连接;而长连接只需要
第一次建立连接,之后的请求、响应都可以直接传输。相对来说建立连接,关闭连接也是要耗时
的,长连接效率更高。 - 主动发送请求不同:短连接一般是客户端主动向服务端发送请求;而长连接可以是客户端主动发送
请求,也可以是服务端主动发。 - 两者的使用场景有不同:短连接适用于客户端请求频率不高的场景,如浏览网页等。长连接适用于
客户端与服务端通信频繁的场景,如聊天室,实时游戏等。
2. TCP 版本的回显服务器
1、先用 ServerSocket 创建一个对象。
private ServerSocket serverSocket = null;
指定一个构造方法来绑定一个端口
public TcpEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
创建一个 start 方法,通过创建的对象来调用 accept 方法(accept 是接收连接)
public void start() throws IOException{
System.out.println("启动服务器!!!");
while (true) {
//使用 clientSocket 和具体的客户端进行交互
Socket clientSocket = serverSocket.accept();
}
}
accept 方法的效果是 “接收连接”,前提是要有客户端来建立连接。
客户端在构造 Socket 对象的时候,就会指定服务器的 IP 和 端口号,如果没有客户端来连接,此时 accept 就会阻塞。
accept 会返回一个 Socket 对象。
Socket clientSocket = serverSocket.accept();
TCP socket 里面涉及到两种 socket 对象
此时的 clientSocket 是一个 socket 对象,serverSocket 也是一个 socket 对象。
下面来举个例子说明。
比如张三去 4s 店买车,刚进店的时候,店员小王就来接待他,在了解了需求后就把张三汽车介绍的员工那里。
这个时候小王就把张三带到李四那里,而李四是专门为顾客介绍汽车有哪些特点等等的。
店员小王负责的是具体的买车服务,而李四负责的是引领顾客到指定位置。
店员小王就相当于是 serverSocket 对象,讲解员李四 就相当于是 clientSocket 对象。
2、接收到连接后,就可以写一个方法拿 clientSocket 与客户端进行交互了。
使用这个方法来处理一个链接,这一个连接对应到一个客户端,但是这里可能会涉及到多次交互。
private void processConnection(Socket cilentSocket) {
System.out.printf("[%s:%d] 客户端上线!\n", cilentSocket.getInetAddress().toString(), cilentSocket.getPort());
}
cilentSocket.getInetAddress() 是获取 ip 地址 ,cilentSocket.getPort() 是获取端口号。
接下来就可以基于上述 socket 对象和客户端进行通信了。
先用 try catch 拿到 InputStream 和 OutputStream。
try (InputStream inputStream = cilentSocket.getInputStream();
OutputStream outputStream = cilentSocket.getOutputStream()){
} catch (IOException e) {
e.printStackTrace();
}
接下来读取请求。
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
// 没有下个数据说明读完了(也就是客户端关闭连接)
System.out.printf("[%s:%d] 客户端下线!\n", cilentSocket.getInetAddress().toString(),
cilentSocket.getPort());
break;
}
//
String request = scanner.next();
注意此处使用的 next 是一直读取到换行符/空格/其他空白符结束,但是最终返回结果里不包含上述空白符。
根据请求构造响应
调用 process 来构造请求,response 来接收返回的 request。
String response = process(request);
public String process(String request) {
return request;
}
返回响应结果
OutputStream 没有 write 字符串这样的功能,可以把 字符串里的字节数组拿出来进行写入,也可以用字符流转换一下。
PrintWriter printWriter = new PrintWriter(outputStream);
// 此时使用 println 来进行写入,让结果中带有一个 \n 换行,方便对端来进行接收解析
printWriter.println(response);
要使用 flush 用来刷新缓冲区,保证当前写入的数据确实是发送出去了。
printWriter.flush();
打印一下日志
System.out.printf("[%s:%d] rep: %s; resp: %s \n", cilentSocket.getInetAddress().toString(),
cilentSocket.getPort(), request, response);
这个代码中用到了一个 cilentSocket ,此时任意一个客户端连上来,都会返回/创建一个 Socket 对象。( Socket 就是文件)
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
每次创建一个 cilentSocket 对象,就要占用一个文件描述符表的位置。
因此在使用完毕之后,就需要进行释放。
前面的 socket 都没有释放,一方面这些 socket 生命周期更长(跟随这个程序),另一方面这些 socket 也不多,数量是固定的。
但是此处的 cilentSocket 数量多,每个客户端都有一个,生命周期也更短。
调用 close 来关闭,并且将 close 放到 finally 里面,保证一定能执行到。
finally {
try {
// 将 close 放到 finally 里面,保证一定能执行到
cilentSocket.close();
}catch (IOException e) {
e.printStackTrace();
}
}
完整代码
package network;
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
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("启动服务器!!!");
while (true) {
//使用 clientSocket 和具体的客户端进行交互
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
// 使用这个方法来处理一个链接
// 这一个连接对应到一个客户端,但是这里可能会涉及到多次交互
private void processConnection(Socket cilentSocket) {
System.out.printf("[%s:%d] 客户端上线!\n", cilentSocket.getInetAddress().toString(), cilentSocket.getPort());
// 基于上述 socket 对象和客户端进行通信
try (InputStream inputStream = cilentSocket.getInputStream();
OutputStream outputStream = cilentSocket.getOutputStream()){
// 由于要处理多个请求和响应,也是使用循环来进行
while (true) {
// 1.读取请求
Scanner scanner = new Scanner(inputStream);
if (!scanner.hasNext()) {
// 没有下个数据说明读完了(也就是客户端关闭连接)
System.out.printf("[%s:%d] 客户端下线!\n", cilentSocket.getInetAddress().toString(),
cilentSocket.getPort());
break;
}
// 注意此处使用的 next 是一直读取到换行符/空格/其他空白符结束,但是最终返回结果里不包含上述空白符
String request = scanner.next();
// 2.根据请求构造响应
String response = process(request);
// 3.返回响应结果
// OutputStream 没有 write String 这样的功能,可以把 String 里的字节数组拿出来进行写入,也可以用字符流转换一下
PrintWriter printWriter = new PrintWriter(outputStream);
// 此时使用 println 来进行写入,让结果中带有一个 \n 换行,方便对端来进行接收解析
printWriter.println(response);
// flush 用来刷新缓冲区,保证当前写入的数据确实是发送出去了
printWriter.flush();
System.out.printf("[%s:%d] rep: %s; resp: %s \n", cilentSocket.getInetAddress().toString(),
cilentSocket.getPort(), request, response);
}
cilentSocket.close();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
// 将 close 放到 finally 里面,保证一定能执行到
cilentSocket.close();
}catch (IOException e) {
e.printStackTrace();
}
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException{
TcpEchoServer tcpEchoServer = new TcpEchoServer(9090);
tcpEchoServer.start(); //启动
}
}
3. TCP 版本的回显客户端
创建一个 socket 对象,客户端使用 Socket 来创建。
private Socket socket = null;
这个 socket 对象 和 服务器的 clientSocket 对象不是同一个对象,相当于是电话的两端。
指定一个构造方法来绑定 ip 和 端口
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
// Socket 构造方法,能够识别点分十进制式的 ip 地址,比 DatagramPacket 更方便
// new 这个对象的同时,就会进行 TCP 连接操作
socket = new Socket(serverIp, serverPort);
}
这里绑定的是服务器的 ip 和 端口。
写一个 start 方法来启动客户端
1、先从键盘上获取用户输入的内容
Scanner scanner = new Scanner(System.in);
String request = scanner.next();
if (request.equals("exit")) {
System.out.println("goodbye");
break;
}
2、把读到的内容构造成请求,发送给服务器
因为 socket 里面包含了一个输入流和一个输出流,借助 输出流来发送,借助输入流来接收。
所以要把这两个流对象准备好,在 while 循环的外面套上一层 try catch。
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while (true) {
}
} catch (IOException e) {
e.printStackTrace();
}
由于 OutputStream 没有写字符串这样的功能,可以拿一个 PrintWriter 类来把它包装一下。
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request); //直接打印
刷新缓冲区,保证数据确实发送出去了。
printWriter.flush();
3、使用 inputStream 读取服务器的响应
Scanner respScanner = new Scanner(inputStream);
String reaponse = respScanner.next();
4、把响应内容显示到界面上
System.out.println(reaponse);
客户端 和 服务器 用到的 API 差不多,只是执行的流程不太一样。
服务器 是先接收再发送,客户端 是先发送再接收。
完整代码
package network;
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;
public TcpEchoClient(String serverIp, int serverPort) throws IOException {
// Socket 构造方法,能够识别点分十进制式的 ip 地址,比 DatagramPacket 更方便
// new 这个对象的同时,就会进行 TCP 连接操作
socket = new Socket(serverIp, serverPort);
}
public void start() {
System.out.println("客户端启动!!!");
Scanner scanner = new Scanner(System.in);
try (InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()){
while (true) {
System.out.print("> ");
// 1.先从键盘上获取用户输入的内容
String request = scanner.next();
if (request.equals("exit")) {
System.out.println("goodbye");
break;
}
// 2.把读到的内容构造成请求,发送给服务器
// OutputStream 没有写字符串这样的功能,可以拿一个 PrintWriter 类来把它包装一下
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request); //直接打印
printWriter.flush(); // 刷新缓冲区,保证数据确实发送出去了
// 3.读取服务器的响应
Scanner respScanner = new Scanner(inputStream);
String reaponse = respScanner.next();
// 4.把响应内容显示到界面上
System.out.println(reaponse);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException{
TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1", 9090);
tcpEchoClient.start();
}
}
运行结果
先运行服务器
再运行客户端。
printWriter.println(response);
printWriter.println(request);
当前的代码里面使用的是 println 来进行发送数据的,而 println 会在发送的数据后面自动加上 \n 换行。
如果不使用 prinln 而是使用 print (不带换行的),这个代码是否可以正常运行???
当前的代码。没有 \n 肯定是不行的!!!
TCP 协议是面向字节流的协议,(字节流的特性:一次读多少个字节随意)但是接收方如何知道这一次读了多少个字节呢???
这就需要在传输数据的时候进行明确的约定,此处的代码中隐式约定了使用 \n 来作为当前的请求/响应分割约定。
\
4. 如何给多个客户端提供服务
当前的服务器,同一时刻只能给一个客户端提供服务,显然这是不科学的。
当前启动服务器后,先启动 客户端1,可以看到正常的上线提醒。
如果再启动 客户端2 就看不到任何提示了。
可以看到不仅没有提示 客户端2 上线,而且 客户端2 发送消息后也没有任何提示。
当把 客户端1 退出后就一切正常了。
而且此时的 客户端2 可以发送消息了。
当有客户端脸上服务器之后,代码就执行到了这个 processConnection 方法里的循环中了。
此时意味着,只要这个循环不结束,processConnection 方法就结束不了,进一步也就无法第二次调用到 accept方法。
也就不能给多个线程提供服务。
解决办法就是:使用多线程。
主线程负责进行 accept,每次收到一个连接,就创建新线程,有这个新线程负责处理这个新的客户端。
每个线程都是独立的执行流,每个独立的执行流都是各自执行各自的逻辑,彼此之间是并发的关系。
不会说这边阻塞,会影响到另一边的执行。
创建一个线程,在这个线程里面调用 processConnection 方法。
// 创建一个线程
Thread thread = new Thread(() ->{
processConnection(clientSocket);
});
如果此时的客户端比较多,创建和销毁线程就会比较频繁,此时建议使用线程池
先创建一个 线程池,再将 processConnection 方法的调用放到线程池中。
// 此处使用 newCachedThreadPool 而不使用 FixedThreadPool,因为创建的线程数量不太应该是是固定的。
ExecutorService threadPool = Executors.newCachedThreadPool();
// 线程池版本
threadPool.submit(() -> {
processConnection(clientSocket);
});
根据代码的结果就可以看到此时就可以支持服务多个客户端了。