基于 Socket 网络编程

前言

我们再进行网络编程时,主要是编写“应用层”代码,如果真正要发送这个数据,需要上层协议调用下层协议,也就是应用层调用传输层,传输层给应用层提供一组 API,统称为 Socket API

一、基于Socket的网络通信传输(传输层)

Socket 套接字,是由系统提供用于网络通信的技术,是基于 TCP/IP 协议的网络通信的基本操作单元。基于Socket 套接字的网络程序开发就是网络编程。

在这一块,我们对于传输层协议,主要学习两种 Socket 套接字:

数据报套接字:使用传输层UDP协议。UDP,即 User Datagram Protocol(用户数据报协议),传输层协议。
以下为UDP的特点(细节后续介绍):

  1. 无连接:使用 udp 通信的双方,不需要刻意保存对端的相关信息。
  2. 不可靠传输:不关注结果
  3. 面向数据报:以一个 udp 数据报为基本单位
  4. 全双工:双向通信(有接收缓冲区,无发送缓冲区)
  5. 大小受限:一次最多传输 64k

流套接字:使用传输层TCP协议。TCP,即 Transmission Control Protocol(传输控制协议),传输层协议。
以下为TCP的特点(细节后续介绍):

  1. 有连接:使用 TCP 通信双方,则需要刻意各自记录了对方的信息
  2. 可靠传输:发送后尽可能的传输过去,失败了也知道
  3. 面向字节流:以字节流为传输的基本单位,读写方式非常灵活
  4. 全双工:双向通信(有接收缓冲区,也有发送缓冲区)
  5. 大小不限

二、UDP 的数据报套接字编程

DatagramSocket 是UDP Socket,用于发送和接收UDP数据报。

这里的 Socket 我们可以类比于 File 对象理解,我们知道如果我们是不能直接操作硬盘的,如果想要操作硬盘就需要借助 File。对象间接操作。Socket 也是类似,它对应于网卡这个硬件设备,如果我们想要操作网卡,就需要一个 Socket 对象间接操作网卡。向 socket 对象中1写数据,就相当于通过网卡发送消息。从 socket 对象中读数据,就相当于通过网卡接收消息。

1、UDP 套接字编程 API

(1)DatagramSocket
DatagramSocket 构造方法

方法签名 方法说明
DatagramSocket() 创建一个UDP数据报套接字的Socket,绑定到本机任意一个随机端口(一般用于客户端)
DatagramSocket(int port) 创建一个UDP数据报套接字的Socket,绑定到本机指定的端口(一般用于服务端)

说明:对于服务器,一般要手动指定一个固定的端口,客户端则不要求。类似于我去食堂吃饭,给我提供食物的窗口就是一个服务器,而我是接收食物的消费者,可看做是客户端,窗口需要有一个固定的窗口号,便于我找到它,而我在享受食物时,没有固定的座位,哪里有空位就做哪里。

DatagramSocket 方法

方法签名 方法说明
void receive(DatagramPacket p) 从此套接字接收数据报(如果没有接收到数据报,该方法会阻塞等待)
void send(DatagramPacket p) 从此套接字发送数据报包(不会阻塞等待,直接发送)
void close() 关闭此数据报套接字

(2)DatagramPacket
DatagramPacket 是 UDP Socket 发送和接收的数据报

DatagramPacket构造方法

方法签名 方法说明
DatagramPacket(byte[] buf, int length) 构造一个DatagramPacket以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收指定长度(第二个参数length)
DatagramPacket(byte[] buf, int offset, int length) 以用来接收数据报,接收的数据保存在字节数组(第一个参数buf)中,接收从offset到指定长度length
DatagramPacket(byte[] buf, int length, InetAddress address, int port) 用来发送数据报,发送的数据为字节数组(第一个参数buf)中,length 为数据长度,address为目标主机的地址,port为目标主机的端口号。
DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port) 用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从offset到 length 为数据长度,address为目标主机的地址,port为目标主机的端口号。
DatagramPacket(byte[] buf, int length, SocketAddress address) 用来发送数据报,发送的数据为字节数组(第一个参数buf)中,length 为数据长度。
DatagramPacket(byte[] buf, int offset, int length, SocketAddress address) 构造一个DatagramPacket以用来发送数据报,发送的数据为字节数组(第一个参数buf)中,从offset到指定长度length。address指定目的主机的IP和端口号

DatagramPacket方法

方法签名 方法说明
InetAddress getAddress() 从接收的数据报中,获取发送端主机IP地址;或从发送的数据报中,获取接收端主机IP地址
int getPort() 从接收的数据报中,获取发送端主机的端口号;或从发送的数据报中,获取接收端主机端口号
byte[] getData() 获取数据报中的数据

2、使用 UDP Socket 实现简单通信

下面我们在 Java 中使用 UDP 协议实现的一个简单的客户端和服务端的通信。

下面的 服务器-客户端 代码看起来挺复杂,其实和数据库中的 JDBC 差不多,都是固定的套路,尽管之后在写更复杂的 服务器-客户端 程序,也都是在这个基础上拓展。例如下面的 UDP Socket 通过传输层实现网络通信,代码无非就下面几个步骤:

1.对于服务器:
(1)读取请求并解析
(2)根据请求计算响应
(3)把响应结果发送到客户端
2.对于客户端:
(1)构造并发送请求
(2)接收服务器返回的响应并解析响应

服务器程序:

import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;

// Echo-回显服务器。客户端发了个请求,服务器返回一个一模一样的响应。
public class UdpEchoSever {
    
    
    // 需要先定义一个 socket 对象,使用网络通信,必须要使用 socekt 对象
    private DatagramSocket socket = null;
    // 绑定一个端口号,不一定能成功,比如某个端口号已经被别的进程占用了,此时这里的绑定操作就会出错。
    // 需要注意的是:同一个主机上,一个端口,同一时刻,只能被一个进程绑定。
    public UdpEchoSever(int port) throws SocketException {
    
    
        socket = new DatagramSocket(port);
    }

    // 启动服务器主逻辑
    public void start() throws IOException {
    
    
        System.out.println("服务器启动!");
        while (true) {
    
    
            // 每次循环,做三件事
            // 1. 读取请求并解析
            //    构造一个空的DatagramPacket对象,用来接收客户端请求
            DatagramPacket requestPacket = new DatagramPacket(new byte[4096],4096);
            //    从网卡上接收请求 此处的 requestPacket 为输出型参数
            socket.receive(requestPacket);
            //    这里为了方便处理这个请求,将数据包转化为 String
            String request = new String(requestPacket.getData(),0,requestPacket.getLength());
            // 2. 根据请求计算响应
            String response = process(request);
            // 3. 把响应结果写回到客户端
            //    根据 response 字符串,构造一个 DatagramPacket
            //    和请求 packet 不同,此处构造响应的时候,需要指定这个包要发给谁
            DatagramPacket responsePacket = new DatagramPacket(response.getBytes(),response.getBytes().length,
                    // requestPacket 是从客户端这里收来的,getSocketAddress 会得到客户端的 ip 何为端口
                    requestPacket.getSocketAddress());
                    
            socket.send(responsePacket);
            // 方面查看,打印一下日志
            // ip 和 端口号 + 请求内容 + 响应内容
            System.out.printf("[%s:%d] req: %s, resp: %s\n",requestPacket.getAddress().toString(),requestPacket.getPort(),request,response);
        }
    }

    // process是请求处理方法,这是服务器中的一个关键环节!!!
    public String process(String request) {
    
    
        return request;
    }
    
	// 主函数
    public static void main(String[] args) throws IOException {
    
    
        UdpEchoSever udpEchoSever = new UdpEchoSever(9090);
        udpEchoSever.start();
    }
}

客户端程序:

import java.io.IOException;
import java.net.*;
import java.util.Scanner;

public class UdpEchoClient {
    
    
    private DatagramSocket socket = null;
    private String serverIP;
    private int serverPort;

    // 客户端启动, 需要知道服务器在哪里!!
    public UdpEchoClient(String serverIP, int serverPort) throws SocketException {
    
    
        // 对于客户端来说, 不需要显示关联端口.
        // 不代表没有端口, 而是系统自动分配了个空闲的端口.
        socket = new DatagramSocket();
        this.serverIP = serverIP;
        this.serverPort = serverPort;
    }

    public void start() throws IOException {
    
    
        // 通过这个客户端可以多次和服务器进行交互.
        Scanner scanner = new Scanner(System.in);
        while (true) {
    
    
            // 1. 先从控制台, 读取一个字符串过来
            //    先打印一个提示符, 提示用户要输入内容
            System.out.print("-> ");
            String request = scanner.next();
            // 2. 把字符串构造成 UDP packet, 并进行发送.
            DatagramPacket requestPacket = new DatagramPacket(request.getBytes(), request.getBytes().length,
                    InetAddress.getByName(serverIP), serverPort);
            socket.send(requestPacket);
            // 3. 客户端尝试读取服务器返回的响应
            DatagramPacket responsePacket = new DatagramPacket(new byte[4096], 4096);
            socket.receive(responsePacket);
            // 4. 把响应数据转换成 String 显示出来.
            String response = new String(responsePacket.getData(), 0, responsePacket.getLength());
            System.out.printf("req: %s, resp: %s\n", request, response);
        }
    }

    public static void main(String[] args) throws IOException {
    
    
    	// 127.0.0.1 是一个特殊的IP地址,表示本机的回环地址。
        UdpEchoClient udpEchoClient = new UdpEchoClient("127.0.0.1", 9090);
        udpEchoClient.start();
    }
}

对于UDP Echo Sever 来说,socket 对象的生命周期伴随整个程序的不需要 close。这个 socket 对象是出了循环就不用了,但是循环结束就意味着 start 结束,意味着 main 方法结束,意味着进程结束,进程结束所有文件资源就自动释放了。

三、TCP 流套接字编程

1、TCP 流套接字编程 API

(1)SeverSocket
ServerSocket 是创建TCP服务端Socket的API。

ServerSocket构造方法

方法签名 方法说明
ServerSocket(int port) 创建一个服务端流套接字Socket,并绑定到指定端口

ServerSocket 方法

方法签名 方法说明
Socket.accept() 开始监听指定端口(创建时绑定的端口),有客户端连接后,返回一个服务端Socket对象,并基于该Socket建立与客户端的连接,否则阻塞等待
void close() 关闭此套接字

(2)Socket
Socket 是客户端Socket,或服务端中接收到客户端建立连接(accept方法)的请求后,返回的服务端Socket。

不管是客户端还是服务端Socket,都是双方建立连接以后,保存的对端信息,及用来与对方收发数据的。

Socket 构造方法

方法签名 方法说明
Socket(String host, intport) 创建一个客户端流套接字Socket,并与对应IP的主机上,对应端口的进程建立连接

Socket 方法

方法签名 方法说明
int getPort() 返回此套接字连接到的远程端口号
InetAddress getInetAddress() 返回套接字所连接的地址
InputStream getInputStream() 返回此套接字的输入流
OutputStream getOutputStream() 返回此套接字的输出流
void close() 关闭此套接字

2、使用 TCP Socket 实现简单通信

下面我们同样写一个简单的基于 TCP 协议实现的回显服务器和客户端,用户可以通过客户端向服务器发送请求并接收响应。

服务器程序:

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;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TcpEchoSever {
    
    
    // 这里有个比喻:
    // severSocket 看做是外场拉客的小哥
    // clientSocket 看做内场服务的小姐姐
    // severSocket 只有一个,clientSocket 会给每个客户端都分配一个

    private ServerSocket serverSocket = null;

    public TcpEchoSever(int port) throws IOException {
    
    
        serverSocket = new ServerSocket(port);
    }

    public void start() throws IOException {
    
    
        System.out.println("服务器启动!");
        ExecutorService pool = Executors.newCachedThreadPool();
        while (true) {
    
    
            Socket clientSocket = serverSocket.accept();
            // 如果直接调用,该方法会影响这个循环的二次执行,导致 accept 不及时
            // 创建新线程,用新线程调用 processConnection
            // 每次来一个新的客户端都创建一个新线程

            // 1.方案一:每次创建线程(每次创建销毁,开销较大)
//            Thread t = new Thread(()->{
    
    
//                processConnection(clientSocket);
//            });
//            t.start();

            // 2.方案二:使用线程池
            pool.submit(()->{
    
    
                try {
    
    
                    processConnection((clientSocket));
                } catch (IOException e) {
    
    
                    e.printStackTrace();
                }
            });

        }
    }

    private void processConnection(Socket clientSocket) throws IOException {
    
    
        // 打印一下日志
        System.out.printf("[%s:%d] 客户端上线!\n",clientSocket.getInetAddress().toString(),
                clientSocket.getPort());
        // try () 这种写法,( ) 中允许写多个流对象,使用 ; 分割
        try (InputStream inputStream = clientSocket.getInputStream();
             OutputStream outputStream = clientSocket.getOutputStream()) {
    
    
            // 为了简单,把字节流包装成了更方便的字符流
            Scanner scanner = new Scanner(inputStream);
            PrintWriter printWriter = new PrintWriter(outputStream);

            // 一次可能发来多个请求,这里规定以 \n 为分隔符
            while (true) {
    
    
                // 1.读取请求
                // 特殊处理一下:
                if (!scanner.hasNext()) {
    
    
                    // 读取的流到了结尾(对端关闭了)
                    System.out.printf("[%s:%d] 客户端下线!\n",clientSocket.getInetAddress().toString(),
                            clientSocket.getPort());
                    break;
                }
                // 直接使用 scanner 读取一段字符串
                String request = scanner.next();
                // 2.根据请求计算响应
                String response = process(request);
                // 3.把响应写会给客户端,不要忘了,响应里也是要带上换行的
                printWriter.println(response);
                // 写网卡为全缓冲,这里使用flush刷新
                printWriter.flush();
                // 最后打印一下日志
                System.out.printf("[%s:%d] req: %s resp: %s\n",clientSocket.getInetAddress().toString(),
                        clientSocket.getPort(),request,response);

            }
        } catch (IOException e) {
    
    
            e.printStackTrace();
        } finally {
    
    
        	// 关闭连接
            clientSocket.close();
        }
    }
    
    // 处理请求
    public String process(String request) {
    
    
        return request;
    }
	// 主方法
    public static void main(String[] args) throws IOException {
    
    
        TcpEchoSever tcpEchoSever = new TcpEchoSever(9090);
        tcpEchoSever.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;

// idea 中默认一个程序只能启动一个,启动多个客户端可配置一下 IDEA。

public class TcpEchoClient {
    
    
    private Socket socket = null;

    // ***只有这里会建立连接,和 Udp 不同***
    public TcpEchoClient(String severIp, int port) throws IOException {
    
    
        // 这个操作就相当于让客户端和服务器建立 TCP 连接
        // 这里的链接连上了,accept 就会返回
        socket = new Socket(severIp,port);
    }

    public void start() {
    
    
        Scanner scanner = new Scanner(System.in);
        try (InputStream inputStream = socket.getInputStream();
             OutputStream outputStream = socket.getOutputStream()) {
    
    
            // 将字节流包装成字符流
            Scanner scannerFromSocket = new Scanner(inputStream);
            PrintWriter printWriter = new PrintWriter(outputStream);
            while (true) {
    
    
                // 1.从键盘上读取用户输入的内容
                System.out.print("->");
                String request = scanner.next();
                // 2.把读取的内容构成请求,发给服务器
                //   注意:这里的发送,是带换行的!
                printWriter.println(request);
                // 写网卡为全缓冲,这里使用flush刷新
                printWriter.flush();
                // 3.从服务器读取响应内容
                String response = scannerFromSocket.next();
                // 4. 把响应的结果显示到控制台上
                System.out.printf("req: %s ; resq: %s\n",request,response);
            }

        } catch (IOException e) {
    
    
            e.printStackTrace();
        }
    }
	// 主方法
    public static void main(String[] args) throws IOException {
    
    
        TcpEchoClient tcpEchoClient = new TcpEchoClient("127.0.0.1",9090);
        tcpEchoClient.start();
    }

}

3、使用 Tcp 协议进行网络传输的“五大要点”

(1)自定义简单的应用层协议
对于客户端及服务端应用程序来说,请求和响应,需要约定一致的数据格式,上述为了简单,做了如下简单约定:

  1. 每个请求是个字符串
  2. 请求和请求之间,使用\n(换行符)分割

由于是回显服务器,响应和请求是一模一样的,因此也遵循上述规则。

(2)写网卡是全缓冲(写文件也是全缓冲)

为了提高IO效率,引入了缓冲区,使用缓冲区可以减少IO次数,提高整体的效率。上述 printWriter.println(“内容”) 过后,内容就被写入到了缓冲区,如果不刷新缓冲区,就要等到缓冲区满,自动刷新到网卡中,所以执行上述程序可能就会出现只请求不响应的情况,为了解决这个问题,我们可以在每次写网卡后,手动进行刷新:printWriter.flush()

(3)长连接 与 短连接

长连接和短连接是指在网络编程中不同的连接方式。

短连接指客户端与服务器建立连接后,在完成一次请求-响应操作之后就会断开连接。每次请求都需要重新建立连接,这种方式可以保证连接使用的资源较少,但也对服务器的压力较大。常用于小数据量的频繁通信场景,例如HTTP协议。

而长连接则是指客户端与服务器建立连接后,在一段时间内可以保持连接状态,多次请求-响应操作共用这一个连接。这种方式相对于短连接可以减少连接建立、关闭的次数,提高了通信效率,但是缺点是需要维护连接的状态,如果长时间没有交互,则需要进行心跳检测等机制来维持连接状态。常用于对实时性要求较高的通信场景,例如即时通讯、游戏等。

在上述TCP协议中使用到长连接。

(4)使用多线程

上述例子的服务器中使用到了多线程,如果不使用多线程,代码可能产生 BUG。因为上述 start 的 while 循环,是用来循环的接收连接,而下面的 processConnection 内部也有一个循环用来循环的处理连接。假设现在来了一个连接,start 方法接收连接后其中的 processConnection 就开始循环的处理这个连接,直到这个连接关闭,但是如果这个期间又有别的客户端进行新的连接,由于当前start中的第一次循环还没结束,就会导致一直阻塞,使其他连接处理不及时。为了解决上述问题,一个很好的办法就是使用多线程,为每个连接都分配一个线程独立处理。

(5)频繁创建,生命周期又短资源的需要 close 及时释放

  • 像上述使用 UDP 协议进行网络通信这种,生命周期伴随整个程序的不需要 close。
  • 在这里,使用 TCP 进行网络通信时,服务器那里的每个 Socket 对象只是给一个连接提供服务的,可能会有很多个连接。在这种情况下,服务器会为每个连接都创建一个新的 Socket 对象,作为后续通信的基础。当这个连接不再需要服务时,需要将相应的 Socket 对象关闭,以便及时释放资源。

猜你喜欢

转载自blog.csdn.net/LEE180501/article/details/133145429
今日推荐