java中TCP实现回显服务器及客户端
作者:小小太空人w
前言:
上篇文章介绍了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可以自动关闭,释放文件描述符表中的资源。
PrintWriter printWriter = new PrintWriter(outputStream); printWriter.println(request); printWriter.flush();
注意:
让用户从控制台输入数据,这里做了一个判断,如果输入“exit”就退出客户端(break直接跳出循环)。
Scanner scanner1 = new Scanner(inputStream); String response = scanner1.next(); System.out.println(response);
注意:
为了直接发送字符串,这里将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(); } }
小结:
在写服务端代码时,需要考虑高并发的情况。我们需要尽可能节省系统资源的利用。
到此这篇关于java中TCP实现回显服务器及客户端的文章就介绍到这了,更多相关java TCP回显服务器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!