目录
1. TCP的Socket API
1.1 TCP的socket api的两个关键类
1. ServerSocket,供服务器使用,使用这个类绑定服务器端口;
2. Socket,既会给服务器用,也会给客户端用;
两个类都用于表示socket文件,即抽象了网卡这样的硬件设备;
1.2 面向字节流的TCP
对于UDP实现网络编程,需要DatagramPacket对象表示一个UDP数据报;
而对于面向字节流的TCP,其传输的基本单位是字节(byte),无需一个类表示TCP数据报;
1.3 UDP与TCP的区别与联系
1. TCP是有连接的,UDP是无连接的:
(1)UDP无连接故而不会保存对端的信息,故而每次通信都需手动指定对端的IP;
(2)TCP有连接故而建立连接后,每次通信无需再手动指定对端IP,
且TCP建立连接无需代码干预,而是系统内核自动负责完成,
对于应用程序,客户端主要是发起建立连接的请求,服务器主要是把建立好的连接从内核中取至应用程序中。
当客户端要与服务器建立连接时,服务器的应用程序是不需要做出任何操作的,也没有任何感知。内核直接完成了建立连接的流程(三次握手),完成流程后,就会在内核的队列中(每个serverSocket都有一个这样的队列)进行排队。应用要想和这个客户端进行通信,就需要通过一个accept方法,把内核队列里已经建立好的连接对象移至应用程序中。
2. TCP是可靠传输,UDP是不可靠的;
3. TCP是面向字节流的,UDP是面向数据报的;
4. TCP和UDP都是全双工;
2. 基于TCP实现回显服务器
2.1 客户端TCPEchoClient
2.1.1 代码
package TestDemo2;
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的同时需要与服务器建立连接
// 具体建立的细节(三次握手)由内核自动负责,无需代码干预
// new 该对象时,操作系统内核就开始建立连接
socket = new Socket(serverIp, serverPort);
}
public void start(){
// TCP客户端的行为与UDP类似
Scanner scanner = new Scanner(System.in);
try(InputStream inputStream = socket.getInputStream();
OutputStream outputStream = socket.getOutputStream()) {
PrintWriter writer = new PrintWriter(outputStream);
Scanner scannerNetwork = new Scanner(inputStream);
while (true) {
// 1. 从控制台读取用户输入的内容;
System.out.print("->");
String request = scanner.next();
// 2. 将字符串作为请求发送给服务器;
writer.println(request);
writer.flush();
// 3. 读取服务器返回的响应
String response = scannerNetwork.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();
}
}
2.2 服务器TCPEchoServer
2.2.1 单线程版代码
package TestDemo2;
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 serverSocket = null;
public TCPEchoServer(int port) throws IOException {
serverSocket = new ServerSocket(port);
}
// start方法循环处理多个连接
public void start() throws IOException {
System.out.println("服务器启动");
while(true){
// 通过accept方法把内核中已经建立好的连接取至应用程序中
// accept返回的对象为一个socket对象
Socket clientSocket = serverSocket.accept();
processConnection(clientSocket);
}
}
// 通过processConnection方法处理当前的连接:循环处理多个请求
public void processConnection(Socket clientSocket) {
// 进入方法先打印一个日志,表示当前有客户端已连接
System.out.printf("[%s:%d] 客户端上线\n",
clientSocket.getInetAddress(), clientSocket.getPort());
// 进行数据的交互
try(InputStream inputStream = clientSocket.getInputStream();
OutputStream outputStream = clientSocket.getOutputStream()) {
// 使用try()方式,避免后续使用完流对象忘记关闭
// 对客户端发来的可能为多条的数据,进行循环处理
while(true){
Scanner scanner = new Scanner(inputStream);
if(!scanner.hasNext()){
// 连接断开则循环应该结束
System.out.printf("[%s:%d] 客户端下线\n",
clientSocket.getInetAddress(), clientSocket.getPort());
break;
}
// 1. 读取请求并解析,以next作为读取请求的方式
String request = scanner.next();
// 2. 根据请求计算响应
String response = process(request);
// 3. 把响应写回客户端
// 可以把String转为字节数组,写入到OutputStream中,
// 也可以使用PrintWriter把OutputStream包裹一下,来写入字符串
PrintWriter printWriter = new PrintWriter(outputStream);
// 此处的println不是打印到控制台,而是写入到OutputStream对应的流对象中,
// 即写入clientSocket中,也就发送给了当前连接的另外一端
printWriter.println(response);
// 使用println带有\n换行也是为了后续客户端这边可以使用scanner.next来读取数据
// 刷新缓冲区
printWriter.flush();
// 4. 打印交互详细信息
System.out.printf("[%s:%d] req = %s, resp = %s\n",
clientSocket.getInetAddress(), clientSocket.getPort(),
request, response);
}
}catch (IOException e){
e.printStackTrace();
}finally {
try {
// 关闭clientSocket对象
clientSocket.close();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
public String process(String request){
return request;
}
public static void main(String[] args) throws IOException {
TCPEchoServer server = new TCPEchoServer(9090);
server.start();
}
}
2.2.2 部分代码解释
1. 注意区别serverSocket对象(ServerSocket类型)与clientSocket对象(Socket类型):
2. clientSocket(Socket类型)获取服务器或客户端的IP与端口号的方法:
3. clientSocket(Socket类型)进行数据传输的方法:
前文已经提及TCP是面向字节流的,InputStream和OutputStream就是字节流,以字节为单位,可以借助这2个对象完成数据的发送与接收。
其中, InputStream进行read操作,即接收数据,OutputStream进行write操作,即发送数据;
4. 读取请求并解析使用next作为读取请求的方式,next的规则是,读到空白符就返回。
空白符是一类特殊的字符,如换行符,回车符,空格,制表符,翻页符等,即客户端发起的请求会以空白符作为结束标记,此处约定为\n;
注意TCP是字节流通信方式,每次传输与读取多少个字节都是非常灵活的,故而往往手动约定一个数据报的具体长度。每次循环一次就处理一个数据报即可。
在这里约定使用\n作为数据报的结束标记,就可以正好搭配scanner.next来完成请求的读取过程。
5. 在返回响应后,需要刷新缓冲区。如果没有刷新操作,可能数据仍然在内存中,没有被写入网卡。
6. 对于UDP中使用的DatagramSocket对象,以及TCP中的ServerSocket对象都可以不关闭,因为在程序中只有这一个对象,其生命周期是贯穿整个程序的,不存在频繁申请但没释放的情况,且也不可以提前释放。
但clientSocket对象则是在循环中,每次有一个客户端来建立连接,就会创建出一个新的clientSocket对象:
并且一个clientSocket(Socket类型)最多使用到该客户端断开连接时,并且clientSocket就失去作用了,如果没有手动close就会导致Socket对象占据着文件描述符表的位置,从而导致文件资源泄露。
同时请注意:
此处仅仅是关闭了clientSocket上自带的流对象,并没有关闭socket对象本身。
可以将clientSocket对象的close操作置于processConnection方法最后的finally执行部分,因为processConnection就是用于处理一个连接的方法,这个方法执行完毕代表该连接也处理完毕,无论是该方法正常执行或是抛出异常后,都会执行到finally部分,对该对象进行close;
2.2.3 单线程版代码改进逻辑
1. IDEA默认只允许一个代码创建一个进程,修改配置选中Allow multiple instanes使得允许运行多个进程;
2. 首先启动TCP服务器,再依次启动两个客户端进行网络通信的验证:
(1)第一个启动的客户端(先启动的客户端)通信测试:
可见第一个启动的客户端与服务器通信正常;
(2)第二个启动的客户端(后启动的客户端)通信测试:
服务器并未提示客户端上线,并未显示客户端信息,客户端也未收到来自服务器的响应,可见第二个启动的客户端与服务器通信失败。
即:
当前程序中,先启动的客户端一切正常,后启动的客户端无法与服务器进行任何交互。
这是当前代码结构导致的bug,当前代码逻辑为,第一个客户端启动后,服务器的accept方法就返回得到一个clientSocket对象,继而进入processConnection方法。又进入一个while循环中,在本循环中需要反复处理客户端发来的请求数据,如果当前客户端没有发送请求,服务器代码就会阻塞在scanner.hasNext处。
此时如果第二个客户端也启动,内核会保证第二个客户端连接建立成功,建立成功后,连接对象就会在内核的队列中排队,等待accept方法取出连接并置于代码中进行处理。
而此时,由于第一个客户端并未断开连接,使得服务器的代码阻塞在processConnection方法的while循环中,无法第二次执行到accept。只有断开第一个客户端的连接,才能使第二个客户端执行到accept方法。
为了实现:在处理第一个客户端请求的过程中,代码能够快速地第二次执行到accept方法,需要使用多线程可以让两个线程并发执行。
3.针对上述问题,解决思路为:
在服务器的start方法中,调用accept接受一个连接后,
创建一个新线程去调用processConnection,令主线程继续执行下一次accept。
即:每有一个客户端,就分配一个新线程。
2.2.4 多线程版代码
多线程版本仅修改start方法即可:
// start方法循环处理多个连接
public void start() throws IOException {
System.out.println("服务器启动");
while(true){
// 通过accept方法把内核中已经建立好的连接取至应用程序中
// accept返回的对象为一个socket对象
Socket clientSocket = serverSocket.accept();
// 创建新线程调用循环处理请求方法
Thread t = new Thread(()->{
processConnection(clientSocket);
});
t.start();
}
}
注:TCP服务器客户端通信会出现并发执行问题是因为两重循环在一个线程中,进入第二重循环后无法执行第一个循环,对于UDP客户端服务器程序,只存在一个循环,不存在类似的问题。
2.2.5 多线程版代码改进逻辑
此时的服务器,每个客户端就要创建一个线程,如果有很多客户端频繁建立、断开连接,就会导致服务器频繁地创建、销毁线程,导致开销非常大。
可以使用线程池,对代码进行进一步的优化。
2.2.6 线程池优化版代码
public void start() throws IOException {
System.out.println("服务器启动");
ExecutorService service = Executors.newCachedThreadPool();
while(true){
// 通过accept方法把内核中已经建立好的连接取至应用程序中
// accept返回的对象为一个socket对象
Socket clientSocket = serverSocket.accept();
// 使用线程池
service.submit(new Runnable() {
@Override
public void run() {
processConnection(clientSocket);
}
});
}
}
注:线程池方法降低频繁创建和销毁线程的开销是有限的,如果同一个时刻有大量的客户端来连接,就会使系统上出现大量的线程,如果线程多达成千上万,服务器必然运行崩溃。
近些年,协程、IO多路复用(一个线程同时处理多个客户端socket)/IO多路连接都可以实现高并发,以支持更多客户端同时访问。
2.3 多线程版程序通信测试
首先启动服务器,再依次启动两个客户端:
(1)客户端1:
(2)客户端2:
(3)服务器:
可见多线程版TCP客户端服务器通信测试成功。