网络编程总结(六):Socket - UDP Socket

TCP 和 UDP 的区别

这个在面试的时候经常会有人问,然而网上大多数的回答基本上是在说:TCP 在连接前需要进行三次握手,而 UDP 不会,从而 TCP 是“安全的”,UDP 是“不安全的”,那究竟为啥可靠为啥不可靠?莫非 UDP 不可靠就不可取吗?在这里详细的总结一下。

首先来说,TCP 和 UDP 都是传输层中的协议。他们都为应用程序提供端到端的通信服务。

TCP连接包括三个状态:创建、传送和终止:

  • 在创建过程中,会进行人们经常说的“三次握手”,在握手的过程中,很多参数会被初始化,从而保证数据的按序传输和连接的强壮性。三次握手完成,连接建立。
  • 在传输过程中,有很多重要的机制来保证传输的可靠性和强壮性:

    • 连接成功之后开始发送数据,发送方会发送一个包含序列号内容数据的报文段给接收方,接收方会以一个没有数据内容的报文段来回复,用一个确认号来表示已经完全接收并请求下一个报文段。
    • 发送方继续发送第二个包含序列号内容数据的报文段,接收方同样会回复一个确认号,发送接收就这样继续下去。
    • 接收方接收到连续多个数据包,假如接受到了 1~5 条,就只需要回复第 5 条就可以了,假如中间丢失一条,例如第 3 条丢失了,那么接收方只能回复第 2 条。
    • 发送方始终没有接受到第 3 条的回复,那么它会重发第 3 条,接收方接受到第 3 条之后,会直接确认第 5 条,因为第 4 条和第 5 条都已经接收到了。

在传输过程中,发送方会将 TCP 报文段的头部和数据部分的和计算出来,再求其反码,就得到了校验和,然后将校验和装入报文中传输,接受者在收到报文后再按相同的算法计算一次校验和,然后和报文中的校验和相比较,这样可以保证报文的完整性和正确性。

  • 连接终止使用了四路握手过程(four-way handshake),在这个过程中每个终端的连接都能独立地被终止。因此,一个典型的拆接过程需要每个终端都提供一对FIN和ACK。

UDP则和 TCP 不一样,在传输前,并不需要进行连接,不管对方能不能接收到,想发就发,所以说 UDP 是一种面向非连接的协议,正式因为这个原因,没有建立连接,使得 UDP 的通信效率很高,也正是因为如此,使得它的可靠性要低于 TCP。

TCP和UDP的区别大致可以归纳为:

  1. TCP 是有序的数据传输,而 UDP 则是无序的。
  2. 在发送过程中,如果数据包丢失,TCP 会重新发送,而 UDP 则不会。
  3. TCP 会舍弃屌重复的数据包,而 UDP 不会。
  4. TCP 是面向连接的,需要先连接成功,才进行传输,而 UDP 则是面向非连接的,不需要先进行连接。

UDP Socket

Java 提供了 DatagramSocket 来帮助我们创建 UDP 协议的 Socket,DatagramSocket 的作用是发送和接收数据报文,DatagramSocket 接收和发送数据都是通过 DatagramPacket 对象完成的。

通俗一点来讲,DatagramSocket 是用来“接收发送和数据报”的,DatagramPacket 就是数据报,根据他们的构造函数和一些常用方法就可以看出来:

DatagramSocket
构造函数:

  • DatagramSocket()
    构造数据报套接字并将其绑定到本地主机上任何可用的端口。
  • DatagramSocket(int port)
    创建数据报套接字并将其绑定到本地主机上的指定端口。
  • DatagramSocket(int port, InetAddress laddr)
    创建数据报套接字,将其绑定到指定的本地地址。
  • DatagramSocket(SocketAddress bindaddr)
    创建数据报套接字,将其绑定到指定的本地套接字地址。

    常用设置方法:

  • setBroadcast(boolean on)
    启用/禁用 SO_BROADCAST。
  • setDatagramSocketImplFactory(DatagramSocketImplFactory fac)
    为应用程序设置数据报套接字实现工厂。
  • setReceiveBufferSize(int size)
    将此 DatagramSocket 的 SO_RCVBUF 选项设置为指定的值。
  • setReuseAddress(boolean on)
    启用/禁用 SO_REUSEADDR 套接字选项。
  • setSendBufferSize(int size)
    将此 DatagramSocket 的 SO_SNDBUF 选项设置为指定的值。
  • setSoTimeout(int timeout)
    启用/禁用带有指定超时值的 SO_TIMEOUT,以毫秒为单位。
  • setTrafficClass(int tc)
    为从此 DatagramSocket 上发送的数据报在 IP 数据报头中设置流量类别 (traffic class) 或服务类型八位组 (type-of-service octet)。

常用查询方法:

  • getBroadcast()
    检测是否启用了 SO_BROADCAST。
  • getChannel()
    返回与此数据报套接字关联的唯一 DatagramChannel 对象(如果有)。
  • getInetAddress()
    返回此套接字连接的地址。
  • getLocalAddress()
    获取套接字绑定的本地地址。
  • getLocalPort()
    返回此套接字绑定的本地主机上的端口号。
  • getLocalSocketAddress()
    返回此套接字绑定的端点的地址,如果尚未绑定则返回 null。
  • getPort()
    返回此套接字的端口。
  • getReceiveBufferSize()
    获取此 DatagramSocket 的 SO_RCVBUF 选项的值,该值是平台在 DatagramSocket 上输入时使用的缓冲区大小。
  • getRemoteSocketAddress()
    返回此套接字连接的端点的地址,如果未连接则返回 null。
  • getReuseAddress()
    检测是否启用了 SO_REUSEADDR。
  • getSendBufferSize()
    获取此 DatagramSocket 的 SO_SNDBUF 选项的值,该值是平台在 DatagramSocket 上输出时使用的缓冲区大小。
  • getSoTimeout()
    获取 SO_TIMEOUT 的设置。
  • getTrafficClass()
    为从此 DatagramSocket 上发送的包获取 IP 数据报头中的流量类别或服务类型。
  • isBound()
    返回套接字的绑定状态。
  • isClosed()
    返回是否关闭了套接字。
  • isConnected()
    返回套接字的连接状态。

其他方法:

  • bind(SocketAddress addr)
    将此 DatagramSocket 绑定到特定的地址和端口。
  • close()
    关闭此数据报套接字。
  • connect(InetAddress address, int port)
    将套接字连接到此套接字的远程地址。
  • connect(SocketAddress addr)
    将此套接字连接到远程套接字地址(IP 地址 + 端口号)。
  • disconnect()
    断开套接字的连接。
  • receive(DatagramPacket p)
    从此套接字接收数据报包。
  • send(DatagramPacket p)
    从此套接字发送数据报包。

    DatagramPacket

构造函数:

  • DatagramPacket(byte[] buf, int length)
    构造 DatagramPacket,用来接收长度为 length 的数据包。
  • DatagramPacket(byte[] buf, int length, InetAddress address, int port)
    构造数据报包,用来将长度为 length 的包发送到指定主机上的指定端口号。
  • DatagramPacket(byte[] buf, int offset, int length)
    构造 DatagramPacket,用来接收长度为 length 的包,在缓冲区中指定了偏移量。
  • DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port)
    构造数据报包,用来将长度为 length 偏移量为 offset 的包发送到指定主机上的指定端口号。
  • DatagramPacket(byte[] buf, int offset, int length, SocketAddress address)
    构造数据报包,用来将长度为 length 偏移量为 offset 的包发送到指定主机上的指定端口号。
  • DatagramPacket(byte[] buf, int length, SocketAddress address)
    构造数据报包,用来将长度为 length 的包发送到指定主机上的指定端口号。

查询函数:

  • getAddress()
    返回某台机器的 IP 地址,此数据报将要发往该机器或者是从该机器接收到的。
  • getData()
    返回数据缓冲区。
  • getLength()
    返回将要发送或接收到的数据的长度。
  • getOffset()
    返回将要发送或接收到的数据的偏移量。
  • getPort()
    返回某台远程主机的端口号,此数据报将要发往该主机或者是从该主机接收到的。
  • getSocketAddress()
    获取要将此包发送到的或发出此数据报的远程主机的 SocketAddress(通常为 IP 地址 + 端口号)。

设置函数:

  • setAddress(InetAddress iaddr)
    设置要将此数据报发往的那台机器的 IP 地址。
  • setData(byte[] buf)
    为此包设置数据缓冲区。
  • setData(byte[] buf, int offset, int length)
    为此包设置数据缓冲区。
  • setLength(int length)
    为此包设置长度。
  • setPort(int iport)
    设置要将此数据报发往的远程主机上的端口号。
  • setSocketAddress(SocketAddress address)
    设置要将此数据报发往的远程主机的 SocketAddress(通常为 IP 地址 + 端口号)。

从上面的一系列方法就可以很明显的看出来,DatagramSocket 就是一个发射接收器,DatagramPacket 才是包含一系列数据的数据报,DatagramPacket 包含了将要发送的数据、数据的长度、远程主机的 IP 地址还有远程主机的端口号。

使用 DatagramSocket 发送、接收数据通常需要下面几个步骤:

  1. 创建 DatagramSocket 对象。
  2. 创建 DatagramPacket 对象,并设置我们要发送的数据,接收端的 IP 地址、端口号等等。
  3. (发送方)调用 DatagramSocket 对象的 send(DatagramPacket p) 方法向外发送数据。
  4. (接收方)调用 DatagramSocket 对象的 receive(DatagramPacket p) 方法接收数据。

事实上,当我们使用 UDP 协议进行 Socket 通信的时候,是没有 Server/Client 的区别的,双方都需要创建 DatagramSocket 对象,接收和发送都需要 DatagramPacket 对象作为数据发送和接收的载体。通常情况下,固定 IP、固定端口的 DatagramSocket 对象所在的程序端被成为服务器端。

一个简单的例子:

接收端

public class UDPReceiver {
    public static void main(String[] args) {
        try {
            byte[] buf = new byte[1024];
            DatagramSocket socket = new DatagramSocket(55555);
            DatagramPacket packet = new DatagramPacket(buf, buf.length);
            socket.receive(packet);
            byte[] data = packet.getData();
            System.out.println(new String(data, 0, data.length));
        } catch (SocketException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

发送端

public class UDPSender {
    public static void main(String[] args) {
        try {
            byte[] bytes = "苍茫的天涯是我的爱".getBytes();
            DatagramSocket socket = new DatagramSocket();
            DatagramPacket packet = new DatagramPacket(bytes, bytes.length);
            packet.setAddress(InetAddress.getByName("127.0.0.1"));
            packet.setPort(55555);
            socket.send(packet);
        } catch (SocketException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (UnknownHostException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        } catch (IOException e) {
            // TODO Auto-generated catch block
            e.printStackTrace();
        }
    }
}

需要注意的是,我们使用 UDP Socket,DatagramPacket 作为数据包,每条报文仅根据该包中包含的信息从一台机器到另外一台机器,从一台机器发送到另一台机器的多个包可能选择不同的路由,也可能按不同的顺序到达。不对包投递做出保证。

DatagramSocket 的作用是用来发送和接收 DatagramPacket 数据报,为了接收广播包,应该将 DatagramSocket 绑定到通配符地址。在某些情况中,将 DatagramSocket 版定到一个更加具体的地址时广播包也可以被接收。

UDP Socket 中的连接

又有疑问了,UDP 不是面向非连接的么?那为什么还会有 connect 这样的方法呢?

来看一个例子,修改上面发送端的代码:

public class UDPSender {
    public static void main(String[] args) {
        try {
            byte[] bytes = "苍茫的天涯是我的爱".getBytes();
            DatagramSocket socket = new DatagramSocket();
            DatagramPacket packet = new DatagramPacket(bytes, bytes.length);
            packet.setAddress(InetAddress.getByName("127.0.0.1"));
            packet.setPort(55555);
            socket.send(packet);
            System.out.println("连接的地址:" + socket.getInetAddress() + "\n连接的端口:" + socket.getPort());
        } catch (SocketException e) {
            e.printStackTrace();
        } catch (UnknownHostException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

输出一下发送端的地址和端口,我们会发现:

连接的地址:null
连接的端口:-1

连接地址是 null,端口是 -1,这是啥情况呢?查看 API 中对 getInetAddress 和 getPort 方法的解释:

getInetAddress:返回此套接字连接的地址。如果套接字未连接,则返回 null。 
getPort:返回此套接字的端口。如果套接字未连接,则返回 -1。 

可以看到,在未连接的情况下,才会发生以上的情况,那么我们调用一下 connect 方法呢?

socket.connect(InetAddress.getByName("127.0.0.1"), 55555);

这下有了:

连接的地址:/127.0.0.1
连接的端口:55555

又有疑问了,不是已经在 DatagramPacket 中指定了目的地址和端口了吗?这里为啥又要绑定一个?我们来把绑定的端口修改一下再运行,结果就是:

Exception in thread "main" java.lang.IllegalArgumentException: connected address and packet address differ

可见,我们绑定的地址和端口是要和 DatagramPacket 中指定的地址端口相同的,这不是脱裤子放屁么?

事实上,我们绑定了地址和端口之后, DatagramPacket 不指定地址和端口也同样有效,把发送端的 setAddress 和 setPort 方法注释掉,发现运行效果没差。

UDP Socket 中的绑定

和 TCP Scoket 一样,有时候我们也需要将连接和本地地址和本地端口绑定,除了直接在构造方法中绑定端口之外也可以调用 DatagramSocket 提供的 bind 方法。

需要注意的是,bind 方法需要在 connect 方法调用之前调用(如果有调用 connect 的话),因为如果如果没有绑定的话,调用 connect 方法会默认进行一个绑定操作,看 connect 的源码就知道,其中有这样一段:

if (!isBound())
          bind(new InetSocketAddress(0));

所以绑定操作需要尽量靠前做。