目录
前言:
Socket API
SeverSocket API
TCP中的长短连接
TCP实现回显服务器
代码实现(有详细解释)
TCP实现回显客户端
代码实现(有详细注释)
小结:
前言:
上篇文章介绍了TCP的特点。由于TCP的特点是有连接,面向字节流,可靠传输等,我们就可以想象到TCP的代码和UDP会有一定的差异。TCP和UDP具体使用哪种协议需要根据实际业务需求来选择。
Socket API
Socket是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。
不管是客户端还是服务端Socket,都是双方建立连接后,保存两端信息,及用来与对方收发数据的。
Socket构造方法
注意:
创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接。当服务端accept()阻塞时,客户端一旦实例出Socket对象,就会建立连接。
Socket方法
注意:
获得套接字输入流。如果建立连接,服务端调用这个方法,就是读取客户端请求。
注意:
获得套接字输出流。如果建立连接,服务端调用这个方法,就是往客户端返回响应。
注意:
连接后获得对方的IP地址。
SeverSocket API
ServerSocket 是创建TCP服务端Socket的API。
ServerSocket构造方法
创建服务端套接字,并绑定端口。这个对象就是用来与客户端建立连接的。
ServerSocket方法
注意:
开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,用来收发数据,否则阻塞等待。
注意:
由于在操做系统中Socket被当作文件处理,那么就需要释放PCB中文件描述符表中的资源,同时断开连接。
TCP中的长短连接
短连接:每次接收到数据并返回响应后,都关闭连接,即是短连接。也就是说,短连接只能一次收发数据。
长连接:不关闭连接,一直保持连接状态,双方不停的收发数据,即是长连接。也就是说,长连接可以多次收发数据。
注意:
1)建立关闭连接耗时:很明显短连接需要不断的建立和断开连接,而长连接只需要一次。长连接耗时要比短连接短。
2)主动发送请求不同:短连接一般是客户端主动向服务端发送请求。长连接客户端可以向服务端主动发送,服务端也可以主动向客户端发送。
3)两者使用场景不同:短连接一般适用于客户端请求频率不高的场景(浏览网页)。长连接一般适用于客户端与服务端通信频繁的场景。(聊天室)
TCP实现回显服务器
首先服务器是被动的一方,我们必须指定端口。然后通过ServerSocket对象中accept()方法建立连接,当返回Socket对象时,处理连接并且将响应写回客户端。
由于不知道客户端什么时候建立连接,那么服务器就需要一直等待(随时待命)。这里使用了死循环的方式,但是不会一直循环,accept()方法当没有连接时就会阻塞等待。
这里是本机到本机的数据发送,即使用环回ip即可。
private ServerSocket serverSocket = null;
public TcpEchoSever(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
注意:
创建ServerSocket对象,并且指定端口号。
Socket clintSocket = serverSocket.accept();
注意:
accept()方法会阻塞等待。客户端Socket对象一旦实例化,就会与服务端建立连接。
processConnection(clintSocket);
注意:
这里通过一个方法来处理连接。这样写会有很大的好处。
try(InputStream inputStream = clintSocket.getInputStream();
OutputStream outputStream = clintSocket.getOutputStream())
注意:
我们首先需要获得读和写的流对象。服务器需要接收请求(读),返回响应(写)。这里使用的是带有资源的try(),这样就会自动关闭流对象。
Scanner scanner = new Scanner(inputStream);
String request = scanner.next();
注意:
这里通过Scanner去从流对象中读取数据。注意这里的next()方法,当读到一个换行符/空格/其他空白符结束,但最终结果不包含上述空白符。
因为我们不清楚客户端连接后发送多少次请求,因此我们采用死循环的方式读和向客户端响应数据。这里不会一直循环因为scanner当读不到数据就会阻塞。
String response = process(request);
public String process(String request) {
return request;
}
注意:
这里通过一个函数来处理请求并且返回处理后结果。由于是回显服务器直接返回即可。
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(response);
printWriter.flush();
注意:
我们为了方便直接写字符串,将outputStream转换成PrintWriter。然后将响应写入到网卡,并且换行。因为客户端和服务端读数据都是需要空白符结束的,所以这里必须有一个空白符。
由于数据首先会写入缓冲区,我们将缓冲区刷新一下保证数据正常写入到文件中(网卡)
finally {
clintSocket.close();
}
注意:
和一个客户端建立连接后,返回Socket对象(使用文件描述表),如果并发量大(会创建很多对象,文件描述符表就有可能满),就可能导致无法创建连接。因此需要保证资源得到释放,包裹在finally里。
特别注意:
上述代码只能处理一个客户端。当代码执行到processConnection函数里,首先是一个死循环,然后还有scanner的阻塞,当处理一个连接代码就会一直在这个函数里。没有办法执行到accept()和客户端连接。想要处理下一个客户端的连接,就必须断开这个客户端,显然这是不合理的。
解决方案:
使用多线程。当有客户端连接后,创建一个线程去处理这个连接,主线程代码继续执行,就会到accept()方法。要是有多个客户端都可以建立连接,并且有独立的线程去处理这些连接,这些线程是并发的关系。
但是存在一个问题,如果并发量足够大(客户端数量非常多),就会创建大量的线程,也会存在大量线程的销毁,这些就会消耗大量的系统资源。因此使用线程池,使用动态变化的线程数量,根据并发量来调整线程数量。而且直接使用线程池中的线程代码上就可以实现,这样就会减少系统资源的消耗。
代码实现(有详细解释)
public class TcpEchoSever {
//Tcp协议服务器,使用ServerSocket类,来建立连接
private ServerSocket serverSocket = null;
public TcpEchoSever(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
public void start() throws IOException {
System.out.println("启动服务器");
//使用线程池,防止客户端数量过多,创建销毁大量线程开销太大
//动态变化的线程池
ExecutorService threadPool = Executors.newCachedThreadPool();
while (true) {
//这里会阻塞,直到和客户端建立连接,返回Socket对象,来和客户端通信
//客户端构造Socket对象时,会指定IP和端口,就会建立连接(客户端主动连接)
Socket clintSocket = serverSocket.accept();
threadPool.submit(() -> {
try {
processConnection(clintSocket);
} catch (IOException e) {
throw new RuntimeException(e);
}
});
//要连接多个客户端,需要多线程去处理连接
//这样才能让主线程继续执行到accept阻塞,然后和其他客户端建立连接(每个线程是独立的执行流,彼此之间是并发的关系)
//如果客户端数量非常大,这里就会创建很多线程,数量过多对于系统来说也是很大的开销(使用线程池)
// Thread t = new Thread(() -> {
// try {
// processConnection(clintSocket);
// } catch (IOException e) {
// e.printStackTrace();
// }
// });
// t.start();
}
}
private void processConnection(Socket clintSocket) throws IOException {
System.out.printf("【%s : %d】客户端上线\n", clintSocket.getInetAddress(), clintSocket.getPort());
//读客户端请求
//处理请求
//将结果写回客户端(响应)
try(InputStream inputStream = clintSocket.getInputStream();
OutputStream outputStream = clintSocket.getOutputStream()) {
//流式数据,循环读取
while (true) {
Scanner scanner = new Scanner(inputStream);
//读取完毕,客户端下线
if(!scanner.hasNext()) {
System.out.printf("【%s : %d】客户端下线\n", clintSocket.getInetAddress(), clintSocket.getPort());
break;
}
//读取请求
// 注意!! 此处使用 next 是一直读取到换行符/空格/其他空白符结束, 但是最终返回结果里不包含上述 空白符 .
String request = scanner.next();
//处理请求
String response = process(request);
//写回客户端处理请求结果(响应)
//为了直接写字符串,这里将字节流转换为字符流
//也可以将字符串转为字节数组
PrintWriter printWriter = new PrintWriter(outputStream);
//写入且换行
printWriter.println(response);
//写入首先是写入了缓冲区,这里为了保险就刷新一下缓冲区
printWriter.flush();
System.out.printf("【%s : %d】请求:%s 响应:%s\n", clintSocket.getInetAddress(), clintSocket.getPort(),
request, response);
}
}catch (IOException e) {
e.printStackTrace();
}finally {
//和一个客户端建立连接后,返回Socket对象(使用文件描述表),如果并发量大(会创建很多对象,文件描述符表就有可能满),就可能导致无法创建连接
//因此需要保证资源得到释放,包裹在finally里
clintSocket.close();
}
}
public String process(String request) {
return request;
}
public static void main(String[] args) throws IOException {
TcpEchoSever tcpEchoSever = new TcpEchoSever(8280);
tcpEchoSever.start();
}
}
TCP实现回显客户端
客户端不需要指定端口号。客户端程序在用户主机上,我们如果指定就有可能和其他程序冲突,因此让操作系统随机分配一个空闲的端口号。客户端需要明确服务端的ip和端口号,这样才能明确哪个主机和哪个进程。
那么服务端为什么可以指定端口号呢?难道就不怕和其他进程端口号冲突吗?(这里详解请看上篇文章的解释)
首先需要明确客户端的工作流程:接收用户输入数据 --> 发送请求 --> 接收响应
public TcpEchoClint(String severIp, int severPort) throws IOException {
socket = new Socket(severIp, severPort);
}
注意:
创建Socket对象,并且指定服务端的ip和端口。当这个对象实例创建完成时,同时也就和服务端建立了连接,通过这个Socket对象就可以发送和接收数据。
这里不需要将字符串ip进行转换,可以自动转换。
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream())
注意:
和服务端一样首先获得输入和输出流。用包含资源的try可以自动关闭,释放文件描述符表中的资源。
System.out.println("请输入请求:");
String request = scanner.next();
if(request.equals("exit")) {
System.out.println("bye bye");
break;
}
注意:
让用户从控制台输入数据,这里做了一个判断,如果输入“exit”就退出客户端(break直接跳出循环)。
PrintWriter printWriter = new PrintWriter(outputStream);
printWriter.println(request);
printWriter.flush();
注意:
为了直接发送字符串,这里将outputStream转换成PrintWriter。这里在发送时需要换行(空白符),因为服务端读取的next()方法需要空白符。
数据首先写入缓冲区,为了保证数据写入到文件(网卡),这里手动刷新一下缓冲区。
Scanner scanner1 = new Scanner(inputStream);
String response = scanner1.next();
System.out.println(response);
注意:
接收响应,通过输入流来读取响应。将接收的响应打印出来。这里的next()方法和上面一致。
代码实现(有详细注释)
public class TcpEchoClint {
Socket socket = null;
public TcpEchoClint(String severIp, int severPort) throws IOException {
//Socket构造方法,可以识别点分十进制,不需要转换,比DatageamPacket方便
//实例这个对象的同时,就会进行连接
socket = new Socket(severIp, severPort);
}
public void start() {
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
Scanner scanner = new Scanner(System.in);
while (true) {
//从控制台读取请求
//空白字符结束,但不会读空白字符
System.out.println("请输入请求:");
String request = scanner.next();
if(request.equals("exit")) {
System.out.println("bye bye");
break;
}
//发送请求
PrintWriter printWriter = new PrintWriter(outputStream);
//需要发送空白符,因为scanner需要空白符
printWriter.println(request);
printWriter.flush();
//接收响应
Scanner scanner1 = new Scanner(inputStream);
String response = scanner1.next();
System.out.println(response);
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException {
TcpEchoClint tcpEchoClint = new TcpEchoClint("127.0.0.1", 8280);
tcpEchoClint.start();
}
}
小结:
在写服务端代码时,需要考虑高并发的情况。我们需要尽可能节省系统资源的利用。