前言
hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
这篇博客会解析基于TCP协议来进行网络编程,进行客户端和服务器之间的交互,TCP传输的是字节流,TCP通信时,客户端和服务器之间要建立一条连接通道,TCP的代码涉及到文件资源释放、以及多客户端访问时线程的分配,相对UDP来说会稍微复杂一些
初学的铁汁对于服务器和客户端交互的代码可能会感觉比较抽象,建议多写几次代码,结合api解析和数据传输流程细品下🐵🎆个人主页:洋不写bug的博客
🎆所属专栏:JavaEE学习
🎆铁汁们对于JavaEE的各种常用核心语法,都可以在上面的前端专栏学习,专栏正在持续更新中🏀,有问题可以写在评论区或者私信我哦~
1,TCP回显服务器/客户端
TCP编程中,有两个核心的类:
- ServerSocket类,创建一个这样的对象,就相当于打开了一个socket文件,这个socket对象是专门给服务器使用的,这个类不负责发送接收,主要负责建立连接
- Socket类,创建一个这样的对象,就相当于打开了一个socket文件,这个类负责发送接收数据,服务器和客户端都会使用
tcp是字节流,读写的时候,是以字节为基本单位的,tcp读写的代码,跟前面文件IO中字节流读写文件的代码是一致的,用inputStream/outputStream
服务器和客户端进行通信流程跟UDP是一样(如下图),只是api有区别
1,服务器
服务器代码逻辑如下:
- ServeSocket类是专门给服务器建立连接用的,构造方法中传入端口号,创建ServeSocket对象
- 在start中写个while(true)循环,因为服务器要多次处理来自客户端的请求,调用serveSocket的accept方法,等待客户端的连接,没有客户端连接时,accept方法会处于阻塞状态;
如果有客户端访问服务器了,accept方法就返回Socket对象,代表服务器与该客户端之间的一条 TCP 通道,后续操作数据都是通过返回的这个socket,serveSocket就不参与了(例如一些售楼部会雇用一些大妈,专门在大街上拉人去售楼部看房,大妈把客户拉到售楼部,交给售楼部专业的置业顾问,给客户详细的讲解房子,接着大妈再去大街上拉人,serveSocket就相当于大妈,只负责把顾客拉到售楼部去,也就是建立连接;socket就类似于置业顾问,连接后的操作,比如说详细介绍房子、讲价、签合同,是置业顾问负责的)
packagenetwork.tcp;importjava.io.IOException;importjava.net.ServerSocket;importjava.net.Socket;publicclassEchoServer{privateServerSocketserverSocket;publicEchoServer(intport)throwsIOException{serverSocket=newServerSocket(port);}publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){Socketsocket=serverSocket.accept();processConnection(socket);}}privatevoidprocessConnection(Socketsocket){}}processConnection方法中写服务器和客户端建立连接后进行通讯的详细过程:
- TCP协议服务器和客户端之间的通信是通过字节流的,字节输出流是向文件中写数据的,字节输入流是从文件中读取数据的;那流程就是客户端通过输出流往socket文件中写入数据,服务器通过输入流读取数据,进行响应后,再通过输出流把数据写到socket文件中,也就是返回给客户端
- 结合文件IO的知识,服务器就可以用字节数组来读取客户端发送的数据,把数据转化为字符串,进行处理,处理后要把数据再转化为字节数组类型,用输出流把数据返回给客户端
- 用这种方法来写代码,就会涉及到很多的字节数组和字符串的相互转化,有个更简单的方法,就可以创建个Scanner对象和PrintWriter对象,分别负责输入和输出
平常用new Scanner(System.in),就是从控制台读取用户的输入,现在写成new Scanner(inputStream),就是让Scanner从inputStream中读取数据,后面直接用scanner.next()来获取数据即可,如果客户端传输的是int类型的数据,就用scanner.nextInt()来读取数据,特别方便
PrintWriter就是将字符串编码为字节,写入 Socket 的字节输出流 OutputStream中,写入时用的是println方法,用System.out调用println方法就是往控制台上输出,PrintWriter调用就是往OutputStream上输出 - PrintWriter将字符串编码成字节后, 是先放到 PrintWriter 的内存缓冲区,不会立刻交给 OutputStream,在发送完成后还需要用flush刷新下,也可以创建PrintWriter对象时,传入一个true,将PrintWriter设置为自动刷新
- 写个if语句,scanner.hasNext()判断下客户端是否已经关闭,如果客户端已经关闭了,就打印客户端下线信息,退出processConnection方法,进入start方法中,等待下一次客户端的连接
可能有的铁汁会这样想:如果客户端连接上了,但是客户端还没有输入,此时scanner.hasNext()的返回值不就是false吗,那就直接显示客户端下线,退出processConnection方法吗?
如果客户端连接上不输入,scanner.hasNext()就会阻塞,等待输入数据;有收到数据才返回 true;只有客户端正常关闭流才返回 false
packagenetwork.tcp;importjava.io.IOException;importjava.io.InputStream;importjava.io.OutputStream;importjava.io.PrintWriter;importjava.net.ServerSocket;importjava.net.Socket;importjava.util.Scanner;publicclassEchoServer{privateServerSocketserverSocket;publicEchoServer(intport)throwsIOException{serverSocket=newServerSocket(port);}publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){Socketsocket=serverSocket.accept();processConnection(socket);}}privatevoidprocessConnection(Socketsocket){System.out.printf("[%s:%d] 客户端上线!\n",socket.getInetAddress().toString(),socket.getPort());try(InputStreaminputStream=socket.getInputStream();OutputStreamoutputStream=socket.getOutputStream()){Scannerscanner=newScanner(inputStream);PrintWriterwriter=newPrintWriter(outputStream,true);while(true){//1.读取请求并解析if(!scanner.hasNext()){System.out.printf("[%s:%d] 客户端下线!",socket.getInetAddress().toString(),socket.getPort());break;}Stringrequest=scanner.next();//2.根据请求计算响应Stringresponse=process(request);//3.把响应写回客户端writer.println(response);System.out.printf("[%s:%d] req:%s; resp: %s\n",socket.getInetAddress().toString(),socket.getPort(),request,response);}}catch(IOExceptione){e.printStackTrace();}}privateStringprocess(Stringrequest){returnrequest;}}2,客户端
UDP协议是无连接的,socket是不能保存服务器IP和服务器端口号的信息的,因此上篇博客在写UDP客户端时,就创建了两个成员变量,serveIP和servePort,在构造方法中给这两个成员变量赋值,构建数据包时再传入这两个成员变量
TCP协议是有连接的,socket对象是可以保存服务器的端口号和IP的,就不需要再去创建成员变量保存IP和端口号,直接在构造方法中传入端口号和IP,创建socket对象即可
Scanner和PrintWriter的用法前面服务器代码中已经解析过了,这里就不再赘述了,客户端读取用户输入,通过PrintWriter把请求发送给服务器,再用Scanner读取服务器的返回,代码如下:
packagenetwork.tcp;importjava.io.IOException;importjava.io.InputStream;importjava.io.OutputStream;importjava.io.PrintWriter;importjava.net.Socket;importjava.util.Scanner;publicclassEchoClient{privateSocketsocket;publicEchoClient(StringserverIP,intserverPort)throwsIOException{socket=newSocket(serverIP,serverPort);}publicvoidstart(){System.out.println("客户端启动!");Scannerscanner=newScanner(System.in);try(InputStreaminputStream=socket.getInputStream();OutputStreamoutputStream=socket.getOutputStream()){ScannerscannerNet=newScanner(inputStream);PrintWriterwriter=newPrintWriter(outputStream);while(true){//1.从控制台读取用户输入System.out.print(">");Stringrequest=scanner.next();//2.构造请求发送给服务器writer.println(request);if(!scannerNet.hasNext()){System.out.println("服务器断开了连接");break;}//3.读取服务器响应Stringresponse=scannerNet.next();//4.把响应显示到控制台上System.out.println(response);}}catch(IOExceptione){e.printStackTrace();}}}3,测试
在服务器类中写个main方法,启动服务器,端口号传为9090
publicstaticvoidmain(String[]args)throwsIOException{EchoServerechoServer=newEchoServer(9090);echoServer.start();}上篇博客用UDP协议写了个回显服务器,main方法启动时传入的端口号也是9090
同时启动这两个服务器,虽然端口号相同,但是并不会报出异常,因为TCP:9090和UDP:9090是两个完全独立的端口资源,操作系统分开管理,并不会有冲突,因为是不同的协议,UDP客户端找UDP服务器,TCP客户端找TCP服务器
启动这两个服务器,不要终止程序,可以在命令行中查一下,就会发现TCP和UDP是可以同时占用9090端口号的
在客户端中写个main方法,启动下客户端,就可以正常接收和读取数据
publicstaticvoidmain(String[]args)throwsIOException{EchoClientechoClient=newEchoClient("127.0.0.1",9090);echoClient.start();}如果把客户端程序终止,那服务器的scanner.hasNext()方法就会返回false,服务器退出processConnection方法,并且显示客户端已经下线
如果客户端和服务器都启动了,服务器程序终止,那客户端的scannerNet.hasNext()就会返回flase,显示服务器断开了连接
这里用一个客户端连接服务器来测试,各项功能似乎都没有问题,但是这个代码中是存在两个比较严重的问题,下面会进行解析😅
2,文件资源泄露问题
在服务器的start方法中,如果没有客户端连接,这个accept方法是处于阻塞状态的,每次有客户端连接,循环一次,就会构造一个socket对象,会占用一个文件描述符
服务器被客户端访问很多次,进程的文件描述符耗尽之后,accept()、new Socket()、getInputStream()方法都会抛出异常,服务器就废掉了
publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){Socketsocket=serverSocket.accept();processConnection(socket);}}解决办法也很简单,在processConnection方法的末尾,加个finally语句,里面关闭下socket对象,这样每次processConnection执行完都会关闭这次连接创建的socket对象
finally{try{socket.close();}catch(IOExceptione){e.printStackTrace();}}UDP服务器并不需要关闭socket资源,因为代码逻辑不同(UDP代码如下所示),UDP服务器整个过程中只创建了一个socket对象,并不是每次有客户端访问都创建一个新的socket对象
packagenetwork.udp;importjava.io.IOException;importjava.net.DatagramPacket;importjava.net.DatagramSocket;importjava.net.SocketException;publicclassEchoServer{privateDatagramSocketsocket;publicEchoServer(intport)throwsSocketException{socket=newDatagramSocket(port);}publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){//1.读取请求并解析DatagramPacketreqPacket=newDatagramPacket(newbyte[4096],4096);socket.receive(reqPacket);Stringrequest=newString(reqPacket.getData(),0,reqPacket.getLength());//2.根据请求计算响应Stringresponse=process(request);//3.把响应写回到客户端DatagramPacketrespPacket=newDatagramPacket(response.getBytes(),response.getBytes().length,reqPacket.getSocketAddress());socket.send(respPacket);System.out.printf("[%s:%d] req:%s, resp:%s\n",reqPacket.getAddress(),reqPacket.getPort(),request,response);}}publicStringprocess(Stringrequest){returnrequest;}publicstaticvoidmain(String[]args)throwsIOException{EchoServerserver=newEchoServer(9090);server.start();}}可能有的铁汁会想:TCP服务器中的scanner和writer用不用手动关闭呢?
并不需要,因为是用try‑with‑resources的方式来写代码的,pocessConnection方法执行完成后,输入流和输出流会自动关闭,Scanner、PrintWriter 对象只是包装对象,底层流已经没了,包装对象本身就失去作用,就会被Java的垃圾回收机制回收
3,多客户端访问问题
服务器基本上都是要同时服务多个客户端的,前面测试TCP服务器时,只测试了一个客户端访问服务器,下面测试下两个客户端同时访问服务器,看能不能正常的访问
可以在IDEA上设置一下,这样运行同一个客户端代码两次,就能够启动两个客户端,点击客户端右上角运行图标旁边的这个下拉框,点击Edit Confjigurations(编辑运行配置)
接着在跳出的这个页面中点击Modify options(修改选项)
把最上面的Allow multiple instance(允许多个实例)打上对号,再点击OK保存即可
实例是一个比较广义的概念,可以把创建的对象称为实例,也可以把进程称为实例,此时就表示可以给当前的客户端程序去启动多个进程(多次运行代码可以产生多个客户端)
设置完成后,先把服务器启动,在执行两次客户端代码,就能启动两个客户端,如下图
但是服务器只有一个客户端的上线记录,而且只有第一个客户端是可以正常跟服务器交互的,第二个客户端输入后没有反应
如果把第一个客户端关掉,这时候服务器就会显示第二个客户端的上线记录,第二个客户端输入后又可以正常返回了
通过测试,就会发现当前的服务器处理客户端是串行处理的,只有等第一个客户端程序终止,才会连接第二个客户端,这显然是不符合服务器要求的,服务器需要能同时处理多个客户端
出现这种情况就是代码的问题,在服务器的start方法中写了一个while(true)循环,在processConnection方法中又写了个while(true)循环
当一个客户端建立连接后,进入processConnection方法,只要这个客户端不终止,processConnection中的while(true)方法就不会退出
也就是在第一个whiile(true)方法中,是一直卡到processConnection(socket);并不会进入下次循环,也不会尝试用accept方法获取新的客户端的连接,因此新的客户段尝试连接服务器就没有反应
while(true){Socketsocket=serverSocket.accept();processConnection(socket);}while(true){//1.读取请求并解析if(!scanner.hasNext()){System.out.printf("[%s:%d] 客户端下线!",socket.getInetAddress().toString(),socket.getPort());break;}Stringrequest=scanner.next();//2.根据请求计算响应Stringresponse=process(request);//3.把响应写回客户端writer.println(response);System.out.printf("[%s:%d] req:%s; resp: %s\n",socket.getInetAddress().toString(),socket.getPort(),request,response);}processConnection(socket)是写到while(true)方法中的,那如何做到第二个while(true)循环不会影响到第一个while(true)循环呢?
解决办法就是线程,创建一个新的线程t去执行processConnection(socket),这样main线程执行第一个while循环,t线程执行第二个while循环,t线程并不会干扰main线程
publicvoidstart()throwsIOException{System.out.println("服务器启动!");while(true){Socketsocket=serverSocket.accept();Threadt=newThread(()->{processConnection(socket);});t.start();}}这样有客户端访问服务器时,服务器就会创建个线程,专门负责处理这个客户端的响应和请求
代码还可以再优化下,如果访问服务器的客户端非常多的话,就会导致线程被频繁的创建和销毁,开销比较大,就可以用线程池来进行优化
就创建个没有线程数量限制的线程池,调用submit方法,每次把任务直接交给线程池即可
publicvoidstart()throwsIOException{System.out.println("服务器启动!");ExecutorServicethreadPool=Executors.newCachedThreadPool();while(true){Socketsocket=serverSocket.accept();threadPool.submit(()->{processConnection(socket);});}}还存在一个问题,如果有非常多的客户端访问服务器,这些客户端连接服务器后,并不会即可销毁,像网页类服务器,客户端用一会就关闭了,不会连接很长时间
但游戏类服务器,客户端连接后普遍要保持连接的时间就比较长,就会在短时间内出现大量的线程,但是操作系统的线程数目是有限的,解决这个问题,就用到了IO复用技术
IO复用技术呢可以类比宿舍带饭来理解
例如博主今天要去小吃街,给室友带饭,博主想吃扬州炒饭、室友A想吃炸串、室友B想吃热干面,那博主带饭就有三种方案:
- 先去买扬州炒饭,在摊子上等老板炒完;拿上扬州炒饭后,再去买炸串,也是在摊子上等老板炸完;拿上炸串后,再去买热干面,等老板拌好,三种饭就齐了,这样等待的时间就是三种饭等待的时间之和
这种就类似于一次服务器只能处理一个客户端,处理完成这个客户端后才能处理下个客户端,也就是串行处理的 - 我们三个人都下楼买,各等各的,这样效率虽然高,但是开销就比较大
就类似于最后一版代码,每次都用线程专门负责处理一个客户端的响应 - 博主直接报完饭后不在摊子上等,立马去下个摊子报饭,接着博主站在这三个摊子的中间,让老板做好了喊一下博主即可,这样等待的时间就是做的最慢的那个的时间
这种方法效率比较高,开销也很小,就是IO复用技术,本质上就是用一个线程等待多个socket,哪个socket有数据了,线程处理就好了,就能在线程有限的前提下,处理更多的客户端了
这个IO复用技术在C++中会重点学习,在Java面试中是很少涉及的,Java标准库中对于多路复用的API也是比较复杂的,在实际的工作中,通常会用框架来封装这些底层操作,因此这里铁汁们只是了解下即可
结语💕💕
- TCP服务器的代码和需要考虑的东西是要比UDP更复杂的,因为TCP 中每一个客户端连接都会对应一套独立的 Socket、输入流与输出流,多个客户端同时接入时,还得借助线程池实现并发处理
- 包括writer和scanner进行写入和读取数据时,初学的铁汁可能会感觉比较抽象,就需要多写几次代码,细品一下
- 下篇博客开始解析网络原理部分的内容