• 【计算机网络】网络编程 Socket


    目录

    1.TCP和UDP的区别

    2.基于UDP的 Socket API

    总结

    3.基于TCP的Socket API

    服务器程序的问题


    网络编程的目的:通过网络,让不同主机之间能够进行通信。

    在进行网络编程的时候,需要操作系统提供一组API,也就是Socket API,才能完成编程。Socket API可以认为是应用层和传输层之间交互的路径。

    Socket:也被称为套接字,就是网络中不同主机上的应用程序之间进行双向通信的端点的抽象。其本质上是一种特殊的文件与普通文件不同的是,普通文件都是在同一台计算机上,两个进程之间传输数据。而Socket可以实现在不同计算机之间传输数据,也就是网络传输数据。比如说打开QQ,打开一个网页,这些都是socket来实现通信的,而网络通信又必须遵守的两个协议,tcp/ip协议和udp协议,Socket里面已经封装好了这两个协议,我们直接拿来用即可。Socket就属于是把"网卡"这个设备给抽象成文件了。往Socket文件中写数据,就相当于通过网卡发送数据;从Socket文件读数据,就相当于通过网卡接收数据。

    传输层提供的网络协议主要有两个:TCP和UDP。


    1.TCP和UDP的区别

    1. TCP是有连接的,UDP是无连接的。
    2. TCP是可靠传输的,UDP是不可靠传输的。
    3. TCP是面向字节流的,UDP是面向数据报的。
    4. TCP和UDP都是全双工的。

    详细来说这四个区别。

    1. TCP是有连接的,UDP是无连接的。

    • 这里的连接只是抽象的说法,就是连接双方各自保存了对方的信息。两台计算机连接就是双方彼此保存了对方的关键信息。TCP想要通信,就要先建立连接,如果一方请求建立连接,而另一方拒绝连接,则无法连接,也就无法通信。而UDP想要通信,直接发送数据即可,不需要关心对方是否同意,UDP自身也不会保存对方的信息。

    2. TCP是可靠传输的,UDP是不可靠传输的。

    举个例子:A给B发送消息,发送成功还是失败,若A这边都能及时知道,那这就是可靠传输,否则就是不可靠传输。但是要想可靠传输,也得付出一些代价:

    • 可靠传输的机制更复杂。
    • 可靠传输的传输效率会降低。

    3. TCP是面向字节流的,UDP是面向数据报的。

    • 即TCP是以字节为基本单位来进行传输的。
    • UDP是以数据报为基本单位进行传输的。
    • 网络通信数据的基本单位有多种说法:数据报(Datagram),数据包(Packet),数据帧(Frame),数据段(Segment)。

    4. TCP和UDP都是全双工的。

    一个信道,允许双向通信,就是全双工。

    一个信道,只能单向通信,就是半双工。

    双向通信的原因:与网线有关,一根网线里通常有8根网线,4个为一组,每一组又是单向传输的,这样这一组负责数据传过来,另一组负责数据传过去。就构成了双向通信。


    2.基于UDP的 Socket API

    在Java中也提供了一组API,用来操作Socket,具体使用DatagramSocket这个类操作。其常用方法有接收数据receive()方法,发送数据send()方法,关闭文件close()方法。DatagramPacket这个类用来创建数据包每次发送数据或者是接收数据都要以数据包的形式传递且发送数据时数据包还要加上发送目的地(IP,端口等)。接收数据时还要提前创建好空的数据报。

    为啥要以数据包的形式接收和发送?

    因为UDP是面向数据包的。是以数据报为基本单位。


    接下来我们实现一个简单的基于UDP的客户端/服务器通信的程序。(回显服务器)

    服务器程序

    1. import java.io.IOException;
    2. import java.net.DatagramPacket;
    3. import java.net.DatagramSocket;
    4. import java.net.SocketException;
    5. //服务器程序
    6. public class MyServer {
    7. private DatagramSocket socket = null;
    8. //port 端口号 服务器需要手动指定端口号
    9. public MyServer(int port) throws SocketException {
    10. //折磨写就是手动指定端口
    11. socket = new DatagramSocket(port);
    12. //这么写就是让系统自动分配端口
    13. //socket = new DatagramSocket();
    14. }
    15. //服务器启动方法start
    16. public void start() throws IOException {
    17. System.out.println("服务器启动!");
    18. while(true) {
    19. // 1.读取请求并解析
    20. //创建一个空的数据报,用来存储服务器接收客户端传来的请求
    21. DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
    22. //讲数据报传入socket中
    23. socket.receive(requestPacket);
    24. //将数据报中存储的客户端的请求数据从字节流转成字符串
    25. String request = new String(requestPacket.getData(),0,requestPacket.getLength());
    26. // 2.根据请求计算响应
    27. String response = process(request);
    28. //将计算后的响应数据一个传入新创建的数据报 参数有 字节数组 字节数组长度 数据报的发送地址
    29. DatagramPacket responsePacket = new DatagramPacket(request.getBytes(),request.getBytes().length,
    30. requestPacket.getSocketAddress());
    31. // 3.把响应写回给客户端 以数据包形式
    32. socket.send(responsePacket);
    33. // 4.打印日志
    34. System.out.printf("[%s:%d] request=%s response=%s\n",requestPacket.getAddress().toString(),
    35. responsePacket.getPort(),request,response);
    36. }
    37. }
    38. private String process(String request) {
    39. return request;
    40. }
    41. public static void main(String[] args) throws IOException {
    42. //设置服务器的端口号为9090
    43. MyServer server = new MyServer(9090);
    44. server.start();
    45. }
    46. }

    客户端程序 

    1. import java.io.IOException;
    2. import java.net.DatagramPacket;
    3. import java.net.DatagramSocket;
    4. import java.net.InetAddress;
    5. import java.net.SocketException;
    6. import java.util.Scanner;
    7. //客户端程序
    8. public class MyClient {
    9. private DatagramSocket socket = null;
    10. //服务器IP
    11. private String serverIp =null;
    12. //服务器端口
    13. private int serverPort = 0;
    14. //构造方法 参数为服务器IP和服务器端口号
    15. public MyClient(String IP,int port) throws SocketException {
    16. //客户端端口号不需要手动指定 由系统自动分配
    17. socket = new DatagramSocket();
    18. this.serverIp = IP;
    19. this.serverPort = port;
    20. }
    21. //客户端启动程序
    22. public void start() throws IOException {
    23. System.out.println("客户端启动!");
    24. Scanner sc = new Scanner(System.in);
    25. while(true) {
    26. // 1.将客户端的请求数据发给服务器
    27. System.out.print("->");
    28. String request = sc.next();
    29. //创建数据报 将请求数据以字节流形式装入包中
    30. //因为是充当发送的数据报 所以数据包中要有 数据+发送目的地
    31. DatagramPacket requestPacket = new DatagramPacket(request.getBytes(),request.getBytes().length,InetAddress.getByName(serverIp),serverPort);
    32. //将数据报发送给服务器
    33. socket.send(requestPacket);
    34. // 2.接收服务器响应回的数据
    35. //再创建一个空的数据包接收服务器返回的数据
    36. DatagramPacket responsePacket = new DatagramPacket(new byte[4096],4096);
    37. //接收服务器返回的响应
    38. socket.receive(responsePacket);
    39. String response = new String(responsePacket.getData(),0,responsePacket.getLength());
    40. System.out.println(response);
    41. }
    42. }
    43. public static void main(String[] args) throws IOException {
    44. //参数为服务器IP地址和端口号
    45. MyClient client = new MyClient("127.0.0.1",9090);
    46. client.start();
    47. }
    48. }

    总结

           基于UCP协议的话:服务器和客户端之间的来回receive和send都是以数据包形式传递数据 所以要经常创建数据包。数据包中可包含数据 发送的目的地。充当发送的数据包中是要有发送的地址的,充当接收的数据包要预先创建一个空的数据包。这就是两种方式下数据包的不同的用法。


    上述代码中的几个细节:

    1.既然说Socket是文件,为啥使用完后不进行close操作呢?不怕文件资源泄露吗?

    • 原因是Socket在整个程序的运行过程中都是需要使用的,并且也没有去频繁地打开文件。我们都知道打开文件,就会占用一个文件描述符表,造成文件资源泄露的原因也是因为文件描述符表满了。而文件描述符表又是在PCB上的,当整个程序运行结束,进程结束了,PCB就会还给操作系统,被销毁,那么文件描述符也就会被回收。

    文件资源泄露的原因:在一个程序中,频繁地打开文件而不去关闭。会使文件描述符表满。


    2.服务器和客户端的端口号都需要手动添加吗?

    • 服务器端口号需要手动指定添加,而客户端端口号不需要指定。
    • 因为服务器是在程序员手里的,是可控的,而不同的用户,其电脑上下载的程序,占用的端口号都不同,如果给客户端去指定端口号可能会引发冲突。而最好的做法是让系统去自动分配一个端口号,这样就可以保证分配的这个端口号一定是不冲突的。

    3.这段代码服务器和客户端各自的工作流程?

    1. 服务器先启动,启动之后,就会进入while循环,执行到receive这里,若是未收到客户端的请求,就会一直阻塞,直到客户端有请求发送过来,才会继续往下执行。
    2. 客户端后启动,也先进入while循环,执行.next(),等待用户输入请求,这里也会阻塞等待。当用户输入请求后,以字节形式封装进数据报,将请求数据包发送(send)给服务器。然后继续往下执行到receive(接收)处,等待服务器响应。
    3. 服务器接收(receive)到请求后,将接收到的数据报里的数据又从字节流转成(解析成)字符串,再执行process操作来处理请求,处理完成后,先将响应数据转成字节数组,再封装入数据报,再返回给客户端。
    4. 客户端收到响应后,从receive处向下执行,再次将数据包中的数据解析成字符串并进行打印。
    5. 服务器执行完一次循环后,又执行到receive这里,等待客户端的请求。客户端执行完一次循环后,又执行到next()这里,等待用户输入请求。也就是双双进入阻塞。

    4.为啥接收数据的时候需要提前手动创建一个有大小的数据报?

    • 因为DatagramPacket不能自己分配内存。因此需要提前手动创建好一个字节数组和长度。

    基于上述代码再写一个简单的翻译器程序(继承)

    1. import java.io.IOException;
    2. import java.net.SocketException;
    3. import java.util.HashMap;
    4. import java.util.Map;
    5. public class udpTranServer extends udpServer{
    6. private Map map = new HashMap<>();
    7. public udpTranServer(int port) throws SocketException {
    8. super(port);
    9. map.put("cat","猫");
    10. map.put("dog","狗");
    11. map.put("tiger","老虎");
    12. //......
    13. }
    14. //重写process方法 服务器处理请求
    15. @Override
    16. public String process(String request) {
    17. //英译汉
    18. String response = map.getOrDefault(request,"没有这个单词!");
    19. return response;
    20. }
    21. public static void main(String[] args) throws IOException {
    22. udpTranServer server = new udpTranServer(9090);
    23. server.start();
    24. }
    25. }

    3.基于TCP的Socket API

    有两个关键的类:

    ServerSocket:只给服务器使用,用来绑定端口号。

    Socket:既给服务器用,也给客户端用,用来接收(receive)和发送(send)数据(字节流)。


    常用的四个方法:

    • 得到对端的网络地址和端口号

    • 得到本地的网络地址和端口号 

     

     


    由于TCP是面向字节流的,以字节为基本单位。所以传输过程中就会用到InputStream和OutputStream流进行接收和发送。通过InputStream进行read操作,就是接收,通过OutputStream进行write操作,就是发送。

    服务器

    1. import java.io.IOException;
    2. import java.io.InputStream;
    3. import java.io.OutputStream;
    4. import java.io.PrintWriter;
    5. import java.net.ServerSocket;
    6. import java.net.Socket;
    7. import java.util.Scanner;
    8. //服务器
    9. public class tcpServer {
    10. private ServerSocket serverSocket = null;
    11. public tcpServer(int port) throws IOException {
    12. //服务器手动指定端口号
    13. serverSocket = new ServerSocket(port);
    14. }
    15. //这个方法用来启动服务器
    16. public void start() throws IOException {
    17. System.out.println("服务器启动!");
    18. while(true) {
    19. //服务器接收请求
    20. Socket requestSocket = serverSocket.accept();
    21. //先需要建立连接 细节流程是由内核完成的 只需要调用即可
    22. processConnection(requestSocket);
    23. }
    24. }
    25. private void processConnection(Socket requestSocket) throws IOException {
    26. //打印日志
    27. System.out.printf("[%s:%d] 上线啦! %s\n",requestSocket.getInetAddress(),requestSocket.getPort());
    28. //处理请求
    29. try(InputStream is = requestSocket.getInputStream();
    30. OutputStream os = requestSocket.getOutputStream()) {
    31. Scanner sc = new Scanner(is);
    32. PrintWriter writer = new PrintWriter(os);
    33. while(true) {
    34. // 字节流转字符流
    35. // 先读 Scanner
    36. if (!sc.hasNext()) {
    37. System.out.printf("[%s:%d] 下线啦!\n",requestSocket.getInetAddress(),requestSocket.getPort());
    38. break;
    39. }
    40. String request = sc.next();
    41. // 处理请求
    42. String response = process(request);
    43. //打印日志
    44. System.out.printf("[%s:%d] req=%s res=%s\n",requestSocket.getInetAddress(),requestSocket.getPort(),request,response);
    45. // 将请求处理结果返回给客户端
    46. // 再写 PrintWriter 或者将 字符串转成字节数组
    47. writer.println(response);
    48. // 字符串转成字节数组
    49. // os.write(response.getBytes());
    50. // 刷新缓冲
    51. writer.flush();
    52. // os.flush();
    53. }
    54. } catch (IOException e) {
    55. e.printStackTrace();
    56. }finally {
    57. requestSocket.close();
    58. }
    59. }
    60. private String process(String request) {
    61. //这里负责处理请求并返回响应
    62. return request;
    63. }
    64. public static void main(String[] args) throws IOException {
    65. tcpServer server = new tcpServer(9090);
    66. server.start();
    67. }
    68. }

    客户端

    1. import java.io.IOException;
    2. import java.io.InputStream;
    3. import java.io.OutputStream;
    4. import java.io.PrintWriter;
    5. import java.net.Socket;
    6. import java.util.Scanner;
    7. //客户端
    8. public class tcpClient {
    9. Socket socket = null;
    10. //需要明确访问服务器的IP地址和端口号
    11. public tcpClient(String serverIp,int serverPort) throws IOException {
    12. //具体链接的细节,不需要我们手动干预,内核自动负责
    13. //当我们new这个对象的时候,操作系统就进行三次握手 具体细节,完成建立的细节
    14. socket = new Socket(serverIp,serverPort);
    15. }
    16. public void start() {
    17. System.out.println("客户端启动!");
    18. Scanner sc = new Scanner(System.in);
    19. try(InputStream is = socket.getInputStream();
    20. OutputStream os = socket.getOutputStream()) {
    21. Scanner res = new Scanner(is);
    22. PrintWriter writer = new PrintWriter(os);
    23. while(true) {
    24. System.out.print("->");
    25. String request = sc.next();
    26. //将请求以字节流形式发送给服务器
    27. //os.write(request.getBytes());
    28. writer.println(request);
    29. //刷新缓冲区
    30. writer.flush();
    31. //os.flush();
    32. //接收服务器传回来的响应
    33. //传回来的是字节流 进一步要转成字符流
    34. String response = res.next();
    35. System.out.println(response);
    36. }
    37. } catch (IOException e) {
    38. e.printStackTrace();
    39. }
    40. }
    41. public static void main(String[] args) throws IOException {
    42. tcpClient client = new tcpClient("127.0.0.1",9090);
    43. client.start();
    44. }
    45. }

    服务器程序的问题

    问题一:为啥光Socket对象要进行close操作而ServerSocket不用close,为啥这个会资源泄露?

    原因是这个对象在while循坏中,每次有一个客户端来请求都会创建一个,这个Socket对象也就会占据文件描述符的位置,久而久之,不及时关闭就会造成文件资源泄露,而同一个代码中ServerSocket不用close,是因为它的对象只有一个,且生命周期贯穿整个程序。

    但是我们不是加了try()语句吗,这种语句形式不是会自动关闭的吗?

    原因是这只是关闭了Socket对象自带的流对象,本身本体Socket对象并没有被关闭。


    问题二:上面这种代码如果启动多个客户端会有什么问题,如何解决?

    虽然客户端是多个,但其实进线程只有一个。 最先启动的客户端是可以正常交互的。而后面启动的这些客户端没有响应。并且当第一个客户端结束后,第二个又可以正常运行,并且会将之前积压的请求由服务器全部正常处理。以此类推,即同一时间只能正常运行一个,仿佛是其他客户端都在等待自己的前一个客户端结束后才活过来。这是与我们服务器的代码逻辑有关,在代码中,我们有两层嵌套while(true)循环,这样第一个客户端上线后,进程就会阻塞在里面的while循环的判断语句hasNext这块,还是上面所说的虽然客户端是多个,但其实线程只有一个。所以只有这个用户下线后,里面的while循环才能跳出来,继续执行。


    解决办法:使用多线程并发执行。 

    1. 把连接操作放在Thread里

    • 这样会有效,但是这样每个客户进来,都会创建一个线程,如果客户端多了,并且频繁地建立连接/断开连接,就会频繁地创建和销毁线程,开销就会很大。还可以采用线程池(2)。

    2. 采用线程池

    线程池的方式虽然可以降低线程的频繁创建与销毁的开销。但如果同一时刻大量用户进行访问,线程池也要同时创建多个线程,也会招架不住。还可以采用协程(3)或IO多路复用(4)。


    3.协程


    4.IO多路复用/IO多路转接

    IO多路复用/IO多路转接:用一个线程可以同时处理多个客户端的Socket。在Java中是NIO,其底层就是IO多路复用。


    代码如下:

    只需要改服务器程序。

    1. import java.io.IOException;
    2. import java.io.InputStream;
    3. import java.io.OutputStream;
    4. import java.io.PrintWriter;
    5. import java.net.ServerSocket;
    6. import java.net.Socket;
    7. import java.util.Scanner;
    8. import java.util.concurrent.ExecutorService;
    9. import java.util.concurrent.Executors;
    10. //服务器
    11. public class tcpServer {
    12. private ServerSocket serverSocket = null;
    13. //使用线程池 保证多个客户端正常运行
    14. ExecutorService service = Executors.newCachedThreadPool();
    15. public tcpServer(int port) throws IOException {
    16. //服务器手动指定端口号
    17. serverSocket = new ServerSocket(port);
    18. }
    19. //这个方法用来启动服务器
    20. public void start() throws IOException {
    21. System.out.println("服务器启动!");
    22. while(true) {
    23. //服务器接收请求
    24. Socket requestSocket = serverSocket.accept();
    25. //先需要建立连接 细节流程是由内核完成的 只需要调用即可
    26. //第一种 最简单的多线程处理
    27. /*
    28. Thread thread = new Thread(() -> {
    29. try {
    30. processConnection(requestSocket);
    31. } catch (IOException e) {
    32. e.printStackTrace();
    33. }
    34. });
    35. thread.start();
    36. */
    37. //第二种 线程池
    38. service.submit(new Runnable() {
    39. @Override
    40. public void run() {
    41. try {
    42. processConnection(requestSocket);
    43. } catch (IOException e) {
    44. e.printStackTrace();
    45. }
    46. }
    47. });
    48. //协程和IO多路复用以后实现
    49. }
    50. }
    51. private void processConnection(Socket requestSocket) throws IOException {
    52. //打印日志
    53. System.out.printf("[%s:%d] 上线啦! \n",requestSocket.getInetAddress(),requestSocket.getPort());
    54. //处理请求
    55. try(InputStream is = requestSocket.getInputStream();
    56. OutputStream os = requestSocket.getOutputStream()) {
    57. Scanner sc = new Scanner(is);
    58. PrintWriter writer = new PrintWriter(os);
    59. while(true) {
    60. // 字节流转字符流
    61. // 先读 Scanner
    62. if (!sc.hasNext()) {
    63. System.out.printf("[%s:%d] 下线啦!\n",requestSocket.getInetAddress(),requestSocket.getPort());
    64. break;
    65. }
    66. String request = sc.nextLine();
    67. // 处理请求
    68. String response = process(request);
    69. //打印日志
    70. System.out.printf("[%s:%d] req=%s res=%s\n",requestSocket.getInetAddress(),requestSocket.getPort(),request,response);
    71. // 将请求处理结果返回给客户端
    72. // 再写 PrintWriter 或者将 字符串转成字节数组
    73. writer.println(response);
    74. // 字符串转成字节数组
    75. // os.write(response.getBytes());
    76. // 刷新缓冲
    77. writer.flush();
    78. // os.flush();
    79. }
    80. } catch (IOException e) {
    81. e.printStackTrace();
    82. }finally {
    83. requestSocket.close();
    84. }
    85. }
    86. private String process(String request) {
    87. //这里负责处理请求并返回响应
    88. return request;
    89. }
    90. public static void main(String[] args) throws IOException {
    91. tcpServer server = new tcpServer(9191);
    92. server.start();
    93. }
    94. }

  • 相关阅读:
    前端框架Vue学习 ——(六)Vue组件库Element
    Citus 11(分布式 PostgreSQL) 文档贡献与本地运行
    【无标题】
    Python批量导入及导出项目中所安装的类库包到.txt文件
    面试题: 线程池的核心参数
    小匠物联获评2023年度浙江省省工业设计企业
    Android 自定义加载动画LoadingView
    【Redis】Docker部署Redis数据库
    Python学习-----Day09
    python5day
  • 原文地址:https://blog.csdn.net/m0_73381672/article/details/133875540