在上一篇文章中,我向大家介绍了有关UDP套接字方面的相关编程。
详见: JavaEE——网络编程(UDP套接字编程)
这篇文章,同样会通过一个简单的回显服务器的形式来解释什么是 TCP流套接字编程。
首先我们要知道的是 TCP 提供的两个主要 API。
这里的 API 主要是两个类,如下:
注:这里只是单纯的解释其中的核心代码,整体代码的逻辑会在后面统一展示
private ServerSocket serverSocket = null;
//构造方法实现创建新的 socket 对象
//这里的 TcpEchoSever 是类名
public TcpEchoSever(int port) throws IOException {
//将端口号传递进来
serverSocket = new ServerSocket(port);
}
这里就使用了,创建一个服务器端流套接字 Socket,并指定到端口。
ServerSocket(int port)
public void start() throws IOException {
System.out.println("启动服务器");
while(true){
//这个 clientSever 是和具体的 客户端进行交流
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
这里的 accept() 方法是 “接受连接” ,前提是得有一个客户端连接。
当客户端在构造 Socket 对象时,就会指定服务器的 IP 和 端口。如果没有客户端来这里连接,此时就会在这里产生阻塞。
这里与客户端交流大致分为下面的几步操作:
//将输入的流元素传入到 scanner 中
Scanner scanner = new Scanner(inputStream);
//判断输入流元素是否读取结束
if(!scanner.hasNext()){
//没有下个数据,就说明读完。(即就是说明客户端关闭了连接)
System.out.printf("[%s:%d] 客户端关闭!\n", clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//如果没有结束,就将元素读取到对应的 String 类型的变量中
//注!! 这里使用的 next 关键字是一直读取到换行符/空格/其他空白字符结束,但是最终返回的内容中,没有 其中的 空白符等。
String request = scanner.next();
简单分析代码
- 这里将输入的流变换成 inputStream 让客户端的数据直接被读取进来。
- 这里通过 hasNext() 方法获取字节元素直至最后
String response = process(request);
实现响应代码
// 因为是回显服务器,所以直接返回元素即可
public String process(String request) {
return request;
}
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处使用 println 进行写入,让结果中带有 /n 换行,方便对端进行接受解析
printWriter.println(request);
// 使用 flush 刷新缓冲区,保证当前写入的信息确实发送出去
printWriter.flush();
System.out.printf("[%s:%d] req: %s; resp: %s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),
request,response);
- 这里的 outputStream 的作用是返回当前套接字的输出流。
- 需要注意的是这里使用 PrintWriter 是将这里的流进行转换。
主要是因为 OutputStream 自身的方法不能够写入字符串,需要使用上面的方法进行转换。- 这里的 printWriter.println(request) 就是将处理响应后的 request 元素写回到网卡中,即就是返回到客户端。
要注意理解此处这两个关键字之间的关系和用法。为了更好的理解,我下面举个例子:
以打电话为例。
假设此时我正在办公室里把电话拿在手上接听电话,此时,突然同事给了我一摞文件需要我签字,此时外放又不方便,于是,我拿出了一个耳机带上来接听,同时进行签字。
这里的手上接听电话,就类似于这里的 outputStream。但是此时又不方便使用。
而这里的戴上耳机接听电话,就类似于此处的 PrintWriter。
虽然方式方法不同,但是都达到了目的。这里的两个方法也是如此。
到这里,这个回显服务器就基本完成了。
虽然上面的代码已经实现了对客户端信息的接受,但是其中存在着一个重要问题,如图:
上述画红线的代码是我们实现对客户端响应的核心代码。
但是我们要知道,一个服务器绝对不是给一个客户端服务的。 但是代码写到这里每次只能处理一个客户端的请求,很显然这不符合我们最基本的需求。
对此,处理的方式也很简单,要处理多个客户端,多线程是一个很好的解决办法。
对代码简单修改:
public void start() throws IOException {
System.out.println("启动服务器");
while(true){
//这个 clientSever 是和具体的 客户端进行交流
Socket clientSocket = serverSocket.accept();
Thread t = new Thread(()->{
processConnection(clientSocket);
});
}
如上,使用多线程包裹了处理客户端信息的方法。虽然解决了问题,但是任然存在缺点。
这里如果有许多客户端频繁建立连接,此时就需要频繁的创建 / 销毁线程。此时的开销就比较繁重。对此,使用线程池是一个很好的办法。
对代码的最终修改
//此处使用 CachedThreadPool,和 使用 FixedThreadPool 都不太合适(线程数不应该有固定的。。)
ExecutorService threadPool = Executors.newCachedThreadPool();
while(true){
//这个 clientSever 是和具体的 客户端进行交流
Socket clientSocket = serverSocket.accept();
// 使用线程池来解决问题
threadPool.submit(()->{
processConnection(clientSocket);
});
}
}
这样就更一步优化了代码。
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Scanner;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class TcpEchoSever {
private ServerSocket serverSocket = null;
//构造方法实现创建新的 socket 对象
public TcpEchoSever(int port) throws IOException {
//将端口号传递进来
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
//创建线程池
//此处使用 CachedThreadPool,和 使用 FixedThreadPool 都不太合适(线程数不应该有固定的。。)
ExecutorService threadPool = Executors.newCachedThreadPool();
while(true){
//这个 clientSever 是和具体的 客户端进行交流
Socket clientSocket = serverSocket.accept();
// //在这里对创建方式进行改变,使用多线程的方式
// // 此处使用多线程的方式对代码进行优化,会出现多次的创建删除线程的操作,此时开销会比较大
// Thread t = new Thread(()->{
// processConnection(clientSocket);
// });
// t.start();
// 使用线程池来解决问题
threadPool.submit(()->{
processConnection(clientSocket);
});
}
}
//使用下面的方法实现和客户端的交流
//这里一个连接实现一个交互,但是要注意的是,这里可能会有多次的交流
private void processConnection(Socket clientSocket){
//先打印一个客户端开启时的响应,打印一下当前的端口号和IP地址
System.out.printf("[%s:%d] 客户端开启\n",clientSocket.getInetAddress().toString(),clientSocket.getPort());
//基于上述的 Socket 对象实现与客户端进行交流
//对输入输出方法进行异常抓取
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()){
//由于要处理多个响应,所以这里使用循环执行
while(true){
//1. 读取请求
//将输入的流元素传入到 scanner 中
Scanner scanner = new Scanner(inputStream);
//判断输入流元素是否读取结束
if(!scanner.hasNext()){
//没有下个数据,就说明读完。(即就是说明客户端关闭了连接)
System.out.printf("[%s:%d] 客户端关闭!\n", clientSocket.getInetAddress().toString(),clientSocket.getPort());
break;
}
//如果没有结束,就将元素读取到对应的 String 类型的变量中
//注!! 这里使用的 next 关键字是一直读取到换行符/空格/其他空白字符结束,但是最终返回的内容中,没有 其中的 空白符等。
String request = scanner.next();
//2. 通过请求计算响应
String response = process(request);
//3,返回计算后的请求结果
// OutputStream 中没有 write String 这样的功能,可以将 String 中的字节数组拿出来进行写入
// 也可以使用字符流进行交换
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处使用 println 进行写入,让结果中带有 /n 换行,方便对端进行接受解析
printWriter.println(request);
// 使用 flush 刷新缓冲区,保证当前写入的信息确实发送出去
printWriter.flush();
System.out.printf("[%s:%d] req: %s; resp: %s\n",clientSocket.getInetAddress().toString(),clientSocket.getPort(),
request,response);
}
}catch (IOException e){
e.printStackTrace();
}
finally {
try {
//将关闭操作放在这里更加合适
//对服务器进行关闭
clientSocket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
//根据请求计算响应
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
//创建服务器并给定端口号
TcpEchoSever tcpEchoSever = new TcpEchoSever(9090);
tcpEchoSever.start();
}
}
有关 TCP 客户端的配置和 UDP 客户端的配置十分相似。都需要两个关键信息:
服务器 IP 和 服务器 端口。
除此之外,我们在前面的第一板块描述过。对于客户端 使用的是Socket 关键字以及其内部的方法。
所以,代码如下:
//对于客户端要使用 Socket 来创建客户端
private Socket socket = null;
//使用构造方法实现客户端
public TcpEchoClient(String severIP, int severPart) throws IOException {
// Socket 构造方法,能够识别点分十进制的 IP 地址,比 DatagramPacket 使用更方便
// new 这个对象的同时,就会进行 TCP 连接操作
socket = new Socket(severIP,severPart);
}
对于客户端代码,需要分为下面三部分:
System.out.println("> ");
String request = scanner.next();
if(request.equals("exit")){
System.out.println("good bye");
break;
}
代码如下:
// 2. 把读到的内容构造成请求,发送回服务器。
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
//此处加上一个 flush 确保数据已经发送
printWriter.flush();
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
System.out.println(response);
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 {
//对于客户端要使用 Socket 来创建客户端
private Socket socket = null;
//使用构造方法实现客户端
public TcpEchoClient(String severIP, int severPart) throws IOException {
// Socket 构造方法,能够识别点分十进制的 IP 地址,比 DatagramPacket 使用更方便
// new 这个对象的同时,就会进行 TCP 连接操作
socket = new Socket(severIP,severPart);
}
public void start(){
System.out.println("客户端启动");
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("good bye");
break;
}
// 实现发送数据
// 2. 把读到的内容构造成请求,发送回服务器。
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
//此处加上一个 flush 确保数据已经发送
printWriter.flush();
// 3. 读取服务器的响应
// 使用这个 scanner 进行读取数据
Scanner respScanner = new Scanner(inputStream);
String response = respScanner.next();
// 4. 将响应内容显示到页面上
System.out.println(response);
}
}catch(IOException e){
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClient client = new TcpEchoClient("127.0.0.1",9090);
client.start();
}
}
到此,对应的客户端和服务器之间的代码都已经实现完毕,在最后,这里还需要在说明一个问题,如下图:
在上图的代码中,客户端和服务器都是用的是 println 对数据进行发送。
我们知道,println 会在发送的数据后面加上 \n 换行。
问题:
呢么,这里不使用 println 而是使用 print(不带换行) 呢么这个代码是否可以正常运行?
其实答案很明确,就是不可以。
TCP 协议是面向字节流的协议,对于读的一方,一次读多少字节都可以。但是,对于接收方,这次需要读多少字节是不明确的。
所以,针对上面的问题,就需要在数据传输中进行明确地约定。在此处的代码中,隐性约定就是使用 \n 来作为当前代码请求和响应的分割约定。