Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

06-01 1403阅读

Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

🔍 开发者资源导航 🔍
🏷️ 博客主页: 个人主页
📚 专栏订阅: JavaEE全栈专栏

内容:

    • socket(套接字)
    • TCP和UDP差别
    • UDP编程
      • 方法
      • 使用
      • 简单服务器实现
      • TCP编程
        • 方法
        • Socket和ServerSocket之间的关系
        • 使用
        • 简单服务器实现
        • 多线程优化

          socket(套接字)

          在网络协议中,应用层是和程序员直接相关的,涉及到的网络通信协议也大多是程序员自定制的。

          而在操作系统中提供了一组API:socket api(传输层给应用层提供的),socket相当于网卡的遥控器,我们可以通过这组api来间接操作网卡。

          socket主要提供了两套,一个是TCP的,另一个是UDP,这两个核心协议差别很大,编写的时候风格不同,因此socket api提供了两套。

          TCP和UDP差别

          TCP:有连接,可靠传输,面向字节流,全双工

          UDP:无连接,不可靠传输,面向数据报,全双工

          • 有连接 & 无连接: 对于TCP来说,TCP协议保存了对端的信息,如果A和B进行通信,A保存B的信息,B也保存A的信息,彼此都知道谁是和它建立联系的。而UDP就不会存储彼此的信息。

          • 可靠传输 & 不可靠传输: 在网络中,数据非常容易出现丢包的情况,因为光信号和电信号可能会收到外界的干扰,本来传输的数据被修改了,这样乱的数据会被丢弃掉。而可靠传输并不是保证数据包100%到达,而是尽可能的提高传输成功率,如果丢包了,也可以感知到,而可靠传输的代价就是效率降低~不可靠传输,只是把数据发送出去了,其他的不管了。

          • 面向字节流 & 面向数据报: TCP读写数据时是以字节为单位的,长度是任意的,可能会出现“粘包”问题。UPD读写数据时以一个数据报为单位,一次必须是一整个不能是半个,严格收到长度的限制,因此不会出现“粘包”问题。

          • 双全工 & 半全工: 一个通信链路层如果支持双向通信则称为双全工,如果只支持单向通信(仅能读或者写)则称之为半全工

            粘包(TCP Stickiness)是指 TCP 协议在传输数据时,多个数据包被接收方当作一个包接收,导致数据解析错误的现象。主要发生在 基于流的传输协议(如 TCP),而 UDP 不会粘包,因为 UDP 是面向消息的协议,每个数据包都有明确的边界。

            UDP编程

            方法

            DatagramSocket是JAVA为UPD提供的实现类。

            构造方法:

            方法用途
            DatagramSocket()构建一个数据报套接字绑定到本地主机的任何可用的端口。
            DatagramSocket(int port)构建一个数据报套接字绑定到本地主机的指定端口。

            核心方法:

            方法用途
            send(DatagramPacket p)从该套接字发送数据报包。
            receive(DatagramPacket p)从该套接字接收数据报包。
            close()关闭该数据报套接字。

            上述我们讲到UDP是以数据报为一个单位进行读写的,而在JAVA中DatagramPacket类表示一个完整的UDP数据报。

            构造方法:

            方法用途
            DatagramPacket(byte[] buf, int length) 接收数据包长度 length 构建。
            public DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)发送有偏置 offset指定端口号指定主机上的数据包长度 length数据报包结构。

            核心方法:

            方法用途
            getData() 返回数据缓冲区。
            getLength() 返回要发送的数据的长度或收到的数据的长度。
            getAddress() 返回的IP地址的机器,这个数据包被发送或从收到的数据报。
            getSocketAddress() 获取SocketAddress(通常是IP地址+端口号)的远程主机,数据包被发送到或来自。

            使用

            构造数据报

            DatagramPacket packet = new DatagramPacket(new byte[1024], 1024);
            
            1. 第一个参数:创建一个长度为1024字节的字节数组

              这个数组将作为数据包的缓冲区,用于存储接收到的网络数据

            2. 第二个参数:1024

              指定缓冲区的大小(这里是1024字节)

              表示这个数据包最多能接收1024字节的数据

            看到这里不知道你是否和我一样好奇,这里为什么要这样设计呢???这里的数值反正都一样,直接传入第一个参数不就可以获得第二个参数吗。

            • 当接收长度
            • 当接收长度 > 缓冲区长度时 会抛出IllegalArgumentException异常

              推荐做法:使接收长度等于缓冲区长度

              特殊场景:当只需要接收部分数据时可以使用不一致设置

              接受数据报

              DatagramSocket socket =  new DatagramSocket(9090);
              //存储字节大小为1024字节数组。
              DatagramPacket packet = new DatagramPacket(new byte[1024], 1024);
              //接收数据
              socket.receive(packet);
              //将有效部分转化成字符串
              String receive = new String(packet.getData(), 0, packet.getLength());
              System.out.println("收到消息:" + receive);
              

              getLength()方法是获取DatagramPacket 的length属性,也就是在初始时设置的1024,但是length在接受到数据报后会被改变,变成实际接收到的字节长度。

              发送数据报

              DatagramSocket socket =  new DatagramSocket(9090);
              //存储字节大小为1024字节数组。
              DatagramPacket packet = new DatagramPacket(new byte[1024], 1024);
              //接收数据
              socket.receive(packet);
              String respose  = "你好呀";
              DatagramPacket resposePacket = new DatagramPacket(respose.getBytes(), respose.getBytes().length, packet.getSocketAddress());
              socket.send(resposePacket);
              

              在这里我们将信息重新发回源地址,那么怎么知道对方的ip呢?我们接收到的数据报里面含有对方的源端口以及源ip地址,那么此时对方的源地址就是我们的目的地址,而我们的getSocketAddress()方法恰好可以返回一个带有端口以及ip地址的类。

              除此之外,还要注意我们的respose.getBytes().length,这里不能使用respose.length,因为字符的长度不等于字节的长度!

              简单服务器实现

              如果你理解了上述的代码,那么你就可以完成一个简单的回显服务器啦~

              所谓回显服务器就是你的询问是什么,回答的就是什么…

              一个服务器程序通常的流程:

              1. 读取请求并解析
              2. 根据请求,计算响应。(服务器最关键的部分)
              3. 返回响应

              服务端代码:

              import java.io.IOException;
              import java.net.DatagramPacket;
              import java.net.DatagramSocket;
              import java.net.SocketException;
              import java.util.Scanner;
              public class UdpechoServer {
                  //服务端
                  DatagramSocket socket = null;
                  public UdpechoServer (int port) throws SocketException {
                      socket = new DatagramSocket(port);
                  }
                  public void start() throws IOException {
                      System.out.println("服务器启动了");
                      while (true) {
                          //存储字节大小为1024字节数组。
                          DatagramPacket packet = new DatagramPacket(new byte[1024], 1024);
                          //接收数据
                          socket.receive(packet);
                          //将有效部分转化成字符串
                          String receive = new String(packet.getData(), 0, packet.getLength());
                          System.out.println("收到消息:" + receive);
                          //根据输入回复
                          String respose = poss(receive);
                          //构建响应的数据报。并且传入目的端口和地址
                          DatagramPacket resposePacket = new DatagramPacket(respose.getBytes(), respose.getBytes().length
                                  , packet.getSocketAddress());
                          socket.send(resposePacket);
                          //打印日志
                          System.out.printf("[%s:%d] req:%s resp:%s\n", packet.getAddress(), packet.getPort(), receive, respose);
                      }
                  }
                  //处理请求的函数,根据需要进行调整;
                  private String poss(String receive) {
                      return receive;
                  }
                  public static void main(String[] args) throws IOException {
                      UdpechoServer udpechoServer = new UdpechoServer(9090);
                      udpechoServer.start();
                  }
              }
              

              关键点解析:

              1. 使用while(ture)是否会出现忙等问题?

              当调用 socket.receive() 时,如果内核的 接收缓冲区(Receive Buffer) 中没有数据,应用程序的线程会被操作系统挂起(进入阻塞状态),并添加到该 Socket 的等待队列中。此时线程不会占用 CPU。。

              1. 服务端的端口号如何设置?

              服务端的端口号一般设置为一个固定值,这样服务端相当于一个固定的商户,而不是流动的小摊,客户可以根据这个地址一直找到你,如果你的地址一直在变化,客户怎么找到你?你总不能一直让客户去找你吧?

              1. DatagramSocket 既然有close方法,是否需要关闭?

              是否需要关闭需要考虑它的生命周期是怎样的,服务器一般是7*24小时的运行的,只要你开着它你就需要保持DatagramSocket 的开启状态,而如果你的服务器关闭了,进程结束时自然会关闭所有的资源,无需手动结束。

              客户端代码:

              import java.io.IOException;
              import java.net.*;
              import java.util.Scanner;
              public class UdpEchoClient {
                  //udp不会保存对方的ip和端口号,因此需要额外进行保存
                  private DatagramSocket socket = null;
                  private String ServerAdress = null;
                  private  int ServerPort;
                  public UdpEchoClient(String adress, int port) throws SocketException {
                      this.ServerPort = port;
                      this.ServerAdress = adress;
                      //客户端的端口号随机生成一个空闲的就行。
                      socket = new DatagramSocket();
                  }
                  public void start() throws IOException {
                      while (true) {
                          Scanner sc = new Scanner(System.in);
                          System.out.print("请输入:");
                          String input = sc.nextLine();
                          //getByName是一个特殊的构造方法
                          DatagramPacket packet = new DatagramPacket(input.getBytes(), input.getBytes().length,InetAddress.getByName(ServerAdress), ServerPort);
                          socket.send(packet);
                          //等待回复
                          System.out.println("等待回复中...");
                          DatagramPacket respose = new DatagramPacket(new byte[1024], 1024);
                          socket.receive(respose);
                          String s = new String(respose.getData(), 0, respose.getLength());
                          System.out.println("回复:" + s);
                      }
                  }
                  public static void main(String[] args) throws IOException {
                      UdpEchoClient client = new UdpEchoClient("127.0.0.1", 9090);
                      client.start();
                  }
              }
              

              关键解析:

              1. 客户端如何设置端口号以及ip地址?

              此处的ip地址127.0.0.1是指向的本机,毕竟咱是在本地上部署的嘛~,因此服务器的ip地址就是本机。

              客户端端口号不建议使用固定值,因为咱无法控制客户端的电脑,万一这个端口号被占用了呢?因此只需要随机找一个空闲的端口号即可。

              2.这里的DatagramSocket 是否需要关闭?

              按理说客户端的是需要关闭的,但是咱这里用的while(ture),因此就没这个必要了。

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              运行结果

              一定要先启动服务端再启动客户端!

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              TCP编程

              方法

              在JAVA中TCP协议使用ServerSocket创建一个服务端流套接字,并绑定到指定端口,作用:揽客,并把它交给Socket。

              构造方法

              方法定义说明
              ServerSocket()创建一个绑定服务器套接字。
              ServerSocket(int port)创建一个服务器套接字,绑定到指定的端口。

              核心方法

              方法定义说明
              accept()监听要对这个套接字作出的连接并接受它。
              close()关闭这个套接字。

              因为TCP协议是通过字节流进行读写的,因此在JAVA中有一个专门的类Socket来实现这个读写功能,但是和UDP不同的是他一次可能会接收到多个请求。

              构造方法

              方法定义说明
              Socket()创建一个连接的套接字,与socketimpl系统默认的类型。
              Socket(InetAddress address, int port)创建一个流套接字,并将其与指定的IP地址中的指定端口号连接起来。

              核心方法

              方法定义说明
              close()关闭这个套接字。
              getPort()返回此套接字连接的远程端口号。
              getOutputStream()返回此套接字的输出流。
              getInputStream()返回此套接字的输入流。

              Socket和ServerSocket之间的关系

              他们之间和UDP的关系不同,UDP的客户端和服务端都需要一个DatagramPacket来进行发送和接受数据。

              而在TCP中,ServerSocket仅用于服务端的建立连接,发送和接受数据是通过Socket的字节流来进行的。

              特性Socket (客户端)ServerSocket (服务端)
              作用主动连接服务端监听端口,接受客户端连接
              连接方式直接连接指定IP和端口被动等待客户端连接
              数据流双向通信不直接处理数据,仅管理连接

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              使用

              接受信息

              //服务端接受信息
              ServerSocket Serversocket = new ServerSocket(9090);
              Socket socket = Serversocket.accept();
              try (InputStream inputStream = socket.getInputStream();
                   OutputStream outputStream = socket.getOutputStream()) {
                          //简化输入输出
                          Scanner sc = new Scanner(inputStream);
                          PrintWriter printWriter = new PrintWriter(outputStream);
                          while (true) {
                              if (!sc.hasNext()) {
                                  break;
                              }
                              //获取输入
                              String receive = sc.next();
                              //处理数据
                              String respose = "hello";
                              //读写缓冲区
                              printWriter.println(respose);
                              //刷新缓冲区
                              printWriter.flush();
                          }
                      } catch (IOException e) {
                          throw new RuntimeException(e);
                      } finally {
                          socket.close();
                      }
              

              相比于UDP的发送与接收,TCP的流程要复杂的多。

              讲解点1:读写数据

              我们知道他们是通过字节流来进行读写的,因此我们在建立连接以后,只需要通过他们的输入输出流来进行操作,输入流是接受对方发送的数据,输出流是主动给对方发送数据。

              此外我们不知道对方什么时候结束连接,因此需要我们使用while(ture)来一直进行判断。

              讲解点2:try-resource-close

              字节流使用完是需要进行关闭的,使用try()包裹内部的资源它在结束后会自动关闭,避免忘记手动关闭。

              讲解点3:Scanner & PrintWriter

              使用这两个类对输入输出流进行封装可以简化我们的读取操作,并且PrintWriter 的println方法在输出的时候还会帮我们加上\n,可以有效分割多次输入或输出的差别。

              讲解点4:刷新缓冲区flush

              当我们使用PrintWriter 的时候会出现一个问题,println仅仅是将数据发送了缓冲区内,并没有真正写入网卡中,这是因为JAVA在设计它的时候为了保证它的高效性,想让用户尽可能的多写入几次,再一起发送出去。

              但是在这里它会造成我们的数据并没有实际发送出去,因此我们需要增添一个flush方法冲刷缓冲区到网卡中去。

              讲解点5:长连接 & 短连接

              所谓短连接就是一次连接只发送一次请求,长连接则是可以有多个请求,而TCP的连接明显是长连接的,建立一次连接客户端可以一直发送请求,直到对方断开连接。

              发送信息

              //需要传入地址和端口号
              Socket socket = new Socket("127.0.0.1", 9090);
              Scanner sc = new Scanner(System.in);
              try (InputStream inputStream = socket.getInputStream();
                   OutputStream outputStream = socket.getOutputStream()) {
                  //简化输入输出
                  Scanner scnet = new Scanner(inputStream);
                  PrintWriter printWriter = new PrintWriter(outputStream);
                  while (true) {
                      String request = sc.next();
                      //发送读入的数据
                      printWriter.println(request);
                      printWriter.flush();
                      //返回的响应数据
                      String respose = scnet.next();
                      System.out.println(respose);
                  }
              } catch (IOException e) {
                  throw new RuntimeException(e);
              }
              

              如果上一个理解了的话,这段代码应该不难理解,在这里的printWriter也是使用的println方法,这样可以约定双方的每一个请求以换行结束。

              简单服务器实现

              这里我们依然是一个回显服务器。

              服务端代码:

              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;
              import java.util.concurrent.ThreadFactory;
              public class TcpEchoServer {
                  ServerSocket Serversocket = null;
                  TcpEchoServer (int port) throws IOException {
                      Serversocket = new ServerSocket(port);
                  }
                  public void start() throws IOException {
                      System.out.println("服务器启动了~");
                      ExecutorService executorService = Executors.newCachedThreadPool();
                      while (true) {
                          Socket socket = Serversocket.accept();
                          executorService.submit(()->{
                              try {
                                  processConnection(socket);
                              } catch (IOException e) {
                                  throw new RuntimeException(e);
                              }
                          });
                      }
                  }
                  private void processConnection(Socket socket) throws IOException {
                      System.out.printf("[%s:%d]客户端上线!\n", socket.getInetAddress(), socket.getPort());
                      try (InputStream inputStream = socket.getInputStream();
                           OutputStream outputStream = socket.getOutputStream()) {
                          //简化输入输出
                          Scanner sc = new Scanner(inputStream);
                          PrintWriter printWriter = new PrintWriter(outputStream);
                          while (true) {
                              if (!sc.hasNext()) {
                                  System.out.printf("[%s:%d]客户端下线!\n", socket.getInetAddress(), socket.getPort());
                                  break;
                              }
                              //获取输入
                              String receive = sc.next();
              //                System.out.println("yes");
                              //处理数据
                              String respose = pross(receive);
                              //读写缓冲区
                              printWriter.println(respose);
                              //刷新缓冲区
                              printWriter.flush();
                              //输出日志
                              System.out.printf("[%s:%d] req:%s rep:%s\n",
                                      socket.getInetAddress(), socket.getPort(), receive, respose);
                          }
                      } catch (IOException e) {
                          throw new RuntimeException(e);
                      } finally {
                          socket.close();
                      }
                  }
                  private String pross(String receive) {
                      return receive;
                  }
                  public static void main(String[] args) throws IOException {
                      TcpEchoServer tcpEchoServer = new TcpEchoServer(9091);
                      tcpEchoServer.start();
                  }
              }
              

              客户端代码:

              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 socket = null;
                  public TcpEchoclient(String adress, int port) throws IOException {
                      socket = new Socket(adress, port);
                  }
                  public void start() {
                      //用户输入内容
                      Scanner sc = new Scanner(System.in);
                      try (InputStream inputStream = socket.getInputStream();
                           OutputStream outputStream = socket.getOutputStream()) {
                          //简化输入输出
                          Scanner scnet = new Scanner(inputStream);
                          PrintWriter printWriter = new PrintWriter(outputStream);
                          while (true) {
                              String request = sc.next();
                              //发送读入的数据
                              printWriter.println(request);
                              printWriter.flush();
                              //返回的响应数据
                              String respose = scnet.next();
                              System.out.println(respose);
                          }
                      } catch (IOException e) {
                          throw new RuntimeException(e);
                      }
                  }
                  public static void main(String[] args) throws IOException {
                      TcpEchoclient echoclient = new TcpEchoclient("127.0.0.1", 9091);
                      echoclient.start();
                  }
              }
              

              关键解析

              1. 是否会出现忙等问题

              这里的accept方法和UPD的类似,没有信息时也会进入阻塞等待。

              而在另一个循环里面sc.hasNext()如果输入流中没有数据,但连接未关闭,hasNext() 也会阻塞(线程挂起),直到数据到达或流关闭。

              1. 粘包问题

              上述我们反复提到过TCP的这个问题,这里我们是通过println的换行符来对多个请求进行了分割。

              除此之外,一点要记得关闭socket对象哦~

              运行结果

              运行顺序:先服务端再客户端。

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              Java网络编程实战:TCP/UDP Socket通信详解与高并发服务器设计

              多线程优化

              通过这个代码不知道你有没有发现他并不支持多线程的操作?因为在服务端的代码里面它一次只能进行一次连接,只有这个连接断开的时候其他连接才有机会。

              因此我们可以使用多线程的方法来解决这个问题:

              public void start() throws IOException {
                  System.out.println("服务器启动了~");
                  while (true) {
                      Socket socket = Serversocket.accept();
                      Thread thread = new Thread(()->{
                          try {
                              processConnection(socket);
                          } catch (IOException e) {
                              throw new RuntimeException(e);
                          }
                      });
                      thread.start();
                  }
              }
              

              使用多线程操作,每个客户端都独享一个线程。

              但是每次都创建和销毁这个线程开销会比较大,因此我们可以在起基础上再使用线程池进行优化。

              public void start() throws IOException {
                  System.out.println("服务器启动了~");
                  ExecutorService executorService = Executors.newCachedThreadPool();
                  while (true) {
                      Socket socket = Serversocket.accept();
                      executorService.submit(()->{
                          try {
                              processConnection(socket);
                          } catch (IOException e) {
                              throw new RuntimeException(e);
                          }
                      });
                  }
              }
              

              如果客户端进一步增加达到1w或者100w,此时多线程/线程池,就会产生大量的线程,因此之后我们可以使用IO多路复用的方法来处理,感兴趣的可以自行了解。


              感谢各位的观看Thanks♪(・ω・)ノ,创作不易,如果觉得有收获的话留个关注再走吧。

免责声明:我们致力于保护作者版权,注重分享,被刊用文章因无法核实真实出处,未能及时与作者取得联系,或有版权异议的,请联系管理员,我们会立即处理! 部分文章是来自自研大数据AI进行生成,内容摘自(百度百科,百度知道,头条百科,中国民法典,刑法,牛津词典,新华词典,汉语词典,国家院校,科普平台)等数据,内容仅供学习参考,不准确地方联系删除处理! 图片声明:本站部分配图来自人工智能系统AI生成,觅知网授权图片,PxHere摄影无版权图库和百度,360,搜狗等多加搜索引擎自动关键词搜索配图,如有侵权的图片,请第一时间联系我们。

目录[+]

取消
微信二维码
微信二维码
支付宝二维码