文章目录
初始TCP套接字
我们在上一节简单的介绍了一下, 套接字的相关特性, 现在回顾一下
TCP的特性
- 有连接: TCP套接字内部天然保存了对端的一些地址信息
- 可靠传输: 尽最大的能力保障数据可以传输到指定位置
- 面向字节流: 使用字节流的方式进行数据的传输, 大小没有限制, 但是可能存在一定的问题, 比如有可能出现粘包问题, 所以我们在传输的时候, 通常会使用一些特定的分隔符对数据的内容进行分割, 我们下面的测试代码也可以体现这一点
- 全双工: 可以同时进行请求和响应
TCP的Socket API
和UDP相同, TCP也属于传输层的范畴, 所以在一台计算机内部, 就属于操作系统的管辖范围, 所以我们处在应用层的Java程序员, 就需要使用JVM提供的API接口进行编程(JVM也是封装了操作系统提供的一些API)
Socket
这个API主要是提供给客户端使用的, 当然服务器端也会使用, 是在服务器的ServerSocket
对象调用accpet
方法的时候作为返回值出现
常用的构造方法
- 构造方法1: 创建一个套接字(未绑定对端地址)
- 构造方法2: 创建一个套接字(绑定了对端的IP和端口号)
注意, 该构造方法的IP
可以直接传入字符串类型, 不用先转化为InetAddress
类型然后传入(其实源码进行了一步转化操作)
常用方法
这个获取InputStream和OutputStream
对象的方法, 可以说是最重要的方法, 因为我们的TCP是面向字节流传输的, 这个方法相当于提供了客户端和服务器端进行访问的通道…
和UDP类似, TCP的Socket操作的网卡资源, 也可以抽象为一种文件资源, 也占用文件操作符表的内存资源, 所以如果我们不用的话, 要及时的调用close
方法进行资源的关闭…
ServerSocket
其实根据名字也不难看出, 这个ServerSocket
API是专门给服务器端使用的, 客户端用的是另一套API, 等会再说
常用的两个构造方法
- 构造方法1: 绑定一个随机的端口号(服务器不常用)
- 构造方法2: 绑定一个固定的端口号(服务器常用的)
常用方法
上文我们说了, 在服务器的ServerSocket
API的使用过程中, 也有Socket
出现的时候, 正是当ServerSocket
想要和客户端的Socket
对象建立通信的时候, 本质上是调用accpet
方法, 返回一个Socket
对象, 然后使用该对象和客户端的Socket
对象, 通过打开的IO通道
进行通信
不再多说, 因为这也是一种文件的资源, 所以当我们不用的时候, 要进行及时的关闭, 避免占用文件操作符表的资源, 但是在真实的服务器的场景中, 使用这个方法的场景是有限的, 因为一般都是 7 * 24 小时持续运行
使用TCP模拟通信
下面我们简单写一个翻译的服务器, 来模拟一下使用TCP协议进行网络通信
服务器端
创建网卡以及构造方法
// 创建一个网卡Socket对象
ServerSocket serverSocket = null;
// 构造方法(传入一个固定的端口号作为服务器固定端口号)
public TcpServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
start方法进行和客户端通信的主流程
// start方法, 与客户端进行通信的主流程
public void start() throws IOException {
// 输出日志信息, 服务器上线
System.out.println("服务器已上线...");
// 使用while循环不断处理客户端发来的连接
while(true){
Socket connection = serverSocket.accept();
// 处理这个连接
processConnection(connection);
}
}
注意accept
方法, 仅仅相当于客户端和服务器端之间建立了连接, 还没有进行请求和响应的内容…(类比)其实就相当于客户端和服务器端进行打电话, 只是拨通了, 还没开始说话
关于长短连接
上面我们说了, 建立连接之后, 才可以进行请求和响应, 那么这个连接中, 是包含一次请求响应还是多次请求响应的 ? 是客户端给服务器端发请求还是服务器端给客户端发请求 ? 这个都是说不准的, 要看具体的使用场景, 所以分为长短连接
- 短连接: 一次连接只有一次请求响应, 比较消耗资源, 通常应用在查看网页, 内容展示等场景
- 长连接: 一次连接中有多次请求响应, 比较节约资源, 且不仅仅可能是服务器端给客户端发请求, 服务器端也可能给客户端发请求, 通常应用在游戏, 在线聊天等场景
处理每一个连接的代码逻辑
关键内容都在注释中了
// 处理连接的方法, 这才是真正的进行请求与响应
private void processConnection(Socket connection){
// 输出日志, 表示客户端上线
System.out.printf("客户端上线[%s:%d]", connection.getInetAddress().toString(), connection.getPort());
// 打开connection的IO通道和客户端的联通(使用try-with-resource机制自动释放资源)
try(InputStream inputStream = connection.getInputStream(); OutputStream outputStream = connection.getOutputStream()) {
// 为了便于我们使用 IO, 我们对上面的输入输出流进行套壳处理(也可以不用)
Scanner in = new Scanner(inputStream);
PrintWriter out = new PrintWriter(outputStream);
// 使用while循环不断尝试读取请求和响应
while(true){
// 1. 读取请求
if(!in.hasNext()){
// 如果没有下一条请求了, 输出日志, 直接退出
System.out.printf("客户端下线[%s:%d]", connection.getInetAddress().toString(), connection.getPort());
break;
}
String req = in.next();
// 2. 处理请求
String resp = process(req);
// 3. 发送请求
out.println(resp);
// 4. 记录日志
System.out.printf("[req: %s, resp: %s]", req, resp);
}
} catch (IOException e) {
e.printStackTrace();
}
}
对请求进行处理的主函数, 和上次UDP一致, 都是汉译英的功能
// 处理请求的主方法(翻译)
private static Map<String, String> chineseToEnglish = new HashMap<>();
static {
chineseToEnglish.put("小猫", "cat");
chineseToEnglish.put("小狗", "dog");
chineseToEnglish.put("小鹿", "fawn");
chineseToEnglish.put("小鸟", "bird");
}
private String process(String req) {
return chineseToEnglish.getOrDefault(req, "未收录该词条");
}
客户端
创建网卡, 构造方法
// 创建网卡
private Socket clientSocket = null;
// 构造方法, 由于是Tcp协议, 所以直接绑定对端的地址信息
public TcpClient(String host, int port) throws IOException {
clientSocket = new Socket(host, port);
}
start方法, 和服务器端的一些内容是相似的
// start方法, 与服务器建立通信, 请求与响应
public void start(){
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream(); Scanner sc = new Scanner(System.in)) {
// 把输入输出流进行包装
Scanner in = new Scanner(inputStream);
PrintWriter out = new PrintWriter(outputStream);
// 使用while循环不断读取用户的请求, 发送请求并接收响应
while(sc.hasNext()){
// 1. 读取请求
String req = sc.next();
// 2. 发送请求
out.println(req);
// 3. 接收响应
String resp = in.next();
// 4. 输出响应内容
System.out.println(resp);
}
} catch (IOException e) {
e.printStackTrace();
}
}
上述测试代码的问题分析
如果我们直接运行上面的代码, 我们就会发现, 是无法直接运行的, 说明上面的代码存在一些问题, 我们现在处理一下这些问题
IO的输入缓冲区的问题
有一些 IO 工具, 在输出的时候, 并不会是真正的输出, 而是将输出的内容放到一个缓冲区
的地方, 必须调用flush()
方法才能够真正的进行数据的发送, 所以我们在 IO 那个章节的时候, 建议是所有输出的流, 我们都进行flush()
方法进行推送, 这是一个非常好的习惯, 所以上面的测试代码, 我们把所有使用out.println()
的位置后面, 都加上flush()
方法进行消息的推送
改进代码如下
// 2. 发送请求
out.println(req);
out.flush();
关于TCP协议中的粘包的问题
由于TCP协议传输的时候, 是通过字节流的方式进行传输的, 所以不同的消息之间, 并没有一个非常明显的界限, 所以我们一般手动进行消息边界的指定, 避免消息的"粘连问题"
上述测试代码的逻辑中, 使用
out.println(req);
因为println
天然的就带有一个换行
, 所以这就成为了一个天然的分割条件
关于如果解决粘包问题, 我们之后会仔细说, 这里只是简单介绍一下…
不能进行多线程通信的问题
分析下面的代码片段
假设有一台服务器, 此时客户端A尝试与服务器建立连接, 然后服务器进行连接的处理, 这时, 服务器就要阻塞等待在in.hasNext()
这里, 如果另一个客户端B也尝试和服务器建立连接, 那此时就不会有任何的反应(因为代码阻塞无法进行连接), 那岂不是一台服务器只能给一台客户端提供服务 ?
显然这样是不合理的, 所以此时我们就引入了多线程来解决这个问题, 通过不同的线程把处理连接的内容和接收连接的内容分隔开, 实质上这也就是早期发明多线程的原因(为了解决服务器开发的问题)
为了不频繁的创建销毁线程导致资源开销太大, 我们又引入了线程池来解决这个问题
处理问题之后的完整代码
启动多个实例
首先要设置启动的时候运行启动多个实例这一项, 来模拟同时启动多个客户端
勾选Allow multiple instances
完整代码
客户端代码
package net_demo1.net_demo04;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.PrintWriter;
import java.net.Socket;
import java.util.Scanner;
/**
* 关于使用Tcp协议的客户端程序的模拟
*/
public class TcpClient {
// 创建网卡
private Socket clientSocket = null;
// 构造方法, 由于是Tcp协议, 所以直接绑定对端的地址信息
public TcpClient(String host, int port) throws IOException {
clientSocket = new Socket(host, port);
}
// start方法, 与服务器建立通信, 请求与响应
public void start(){
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream(); Scanner sc = new Scanner(System.in)) {
// 把输入输出流进行包装
Scanner in = new Scanner(inputStream);
PrintWriter out = new PrintWriter(outputStream);
// 使用while循环不断读取用户的请求, 发送请求并接收响应
while(sc.hasNext()){
// 1. 读取请求
String req = sc.next();
// 2. 发送请求
out.println(req);
out.flush();
// 3. 接收响应
String resp = in.next();
// 4. 输出响应内容
System.out.println(resp);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpClient tcpClient = new TcpClient("127.0.0.1", 9090);
tcpClient.start();
}
}
服务器端代码
package net_demo1.net_demo04;
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.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 使用TCP协议模拟的服务器
*/
public class TcpServer {
// 创建一个网卡Socket对象
ServerSocket serverSocket = null;
// 构造方法(传入一个固定的端口号作为服务器固定端口号)
public TcpServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
// start方法, 与客户端进行通信的主流程
public void start() throws IOException {
// 创建一个线程池
ExecutorService executorService = Executors.newCachedThreadPool();
// 输出日志信息, 服务器上线
System.out.println("服务器已上线...");
// 使用while循环不断处理客户端发来的连接
while (true) {
Socket connection = serverSocket.accept();
executorService.execute(() -> {
// 处理这个连接
processConnection(connection);
});
}
}
// 处理连接的方法, 这才是真正的进行请求与响应
private void processConnection(Socket connection) {
// 输出日志, 表示客户端上线
System.out.printf("客户端上线[%s:%d]\n", connection.getInetAddress().toString(), connection.getPort());
// 打开connection的IO通道和客户端的联通(使用try-with-resource机制自动释放资源)
try (InputStream inputStream = connection.getInputStream(); OutputStream outputStream = connection.getOutputStream()) {
// 为了便于我们使用 IO, 我们对上面的输入输出流进行套壳处理(也可以不用)
Scanner in = new Scanner(inputStream);
PrintWriter out = new PrintWriter(outputStream);
// 使用while循环不断尝试读取请求和响应
while (true) {
// 1. 读取请求
if (!in.hasNext()) {
// 如果没有下一条请求了, 输出日志, 直接退出
System.out.printf("客户端下线[%s:%d]\n", connection.getInetAddress().toString(), connection.getPort());
break;
}
String req = in.next();
// 2. 处理请求
String resp = process(req);
// 3. 发送请求
out.println(resp);
out.flush();
// 4. 记录日志
System.out.printf("[req: %s, resp: %s]\n", req, resp);
}
} catch (IOException e) {
e.printStackTrace();
}
}
// 处理请求的主方法(翻译)
private static Map<String, String> chineseToEnglish = new HashMap<>();
static {
chineseToEnglish.put("小猫", "cat");
chineseToEnglish.put("小狗", "dog");
chineseToEnglish.put("小鹿", "fawn");
chineseToEnglish.put("小鸟", "bird");
}
private String process(String req) {
return chineseToEnglish.getOrDefault(req, "未收录该词条");
}
public static void main(String[] args) throws IOException {
TcpServer tcpServer = new TcpServer(9090);
tcpServer.start();
}
}
测试结果