【Java 网络编程】6 - 1 使用 TCP 或 UDP 通信

§6-1 使用 TCP 或 UDP 通信

6-1.1 IP 常见软件架构和三要素

到目前为止,我们所编写的程序都并未涉及和不同设备之间的通信。网络编程,指的是在网络通信协议下,不同计算机上运行的程序,进行数据传输。

网络编程的应用场景非常广,几乎覆盖了日常生活的方方面面,例如即时通信、网游对战、金融证券、国际贸易、邮件等。

Java 的 java.net 包允许开发者使用其中的技术开发常见的网络应用程序。

网络编程的常见软件架构有两种:

  • B/S 模型:即 Browser/Server (浏览器/服务器)模型。用户只需要打开浏览器访问不同网址即可访问不同的服务器。

    优点:用户无需单独下载独立的客户端,只需要打开浏览器访问即可。服务器端发生改动时,用户只需在浏览器中刷新网页即可。无需单独开发客户端,只需要开发页面和服务端。

    缺点:所有的数据都需要通过网络传输,因此无法传输高精细度的数据(如画面、音乐等)。应用过大,用户体验会受到影响。

  • C/S 模型:即 Client/Server (客户端/服务端)模型。用户需要在本地通过独立的客户端程序访问来自远程服务器端的程序所提供的服务。

    优点:高精细度的数据集成在客户端中,用户体验好。

    缺点:用户需要下载独立的客户端,也需要开发对应的服务端。服务端发生改动时,客户端也需要一并改动,更新过程较麻烦。

这两种架构的客户端和浏览器仅仅只是将数据展示给用户,业务的真正核心逻辑位于服务器中。两种架构有优有劣,实际开发中基于不同的项目需要选择。

B/S 模型适用于移动互联网应用,可以在任何地方随时访问的系统。C/S 模型适合定制化的办公类软件和网络游戏。

要想让网络中的两台计算机相互通信,应当确定对方的地址、端口和通信协议。这也是网络编程三要素

  • IP 地址:设备在网络中的地址,唯一标识;
  • 端口:应用程序在设备中的唯一标识;
  • 协议:数据在网络中的传输规则,常见的协议有 UDP, TCP, HTTP, HTTPS, FTP;

6-1.2 IP 网际协议

IP,全称为 Internet Protocol,即网际协议。IP 地址即网际协议地址,是分配给联网设备的数字标签。

每一台联网的设备都有一个唯一标识的 IP 地址。现有两种 IP 地址:IPv4 和 IPv6。

IPv4 地址:全称为 Internet Protocol version 4,网络协议第四版。

IPv4 使用 32 位(4 字节)二进制数字串唯一标识一台联网设备在网络中的地址。通常使用点分十进制表示法,将 32 位分成 4 个 8 位十进制数字(无负值),每一个数字用点号分隔。

11000000 10101000 000000001 01000010 -> 192.168.1.66

每一个八位十进制数字表示范围 \(0 \sim 255\),则 IPv4 地址总计可唯一标识 \(2^{32} \approx 4.3 \times 10^{9}\) 台设备。这个数量显然不够用,在 2019 年11 月 26 日全部分配完毕。面对愈来愈大的 IP 地址需求,后引入了 IPv6 地址,逐渐取代 IPv4 地址。

IPv4 仍然是当今的主流方案,但 IP 分配不足的问题仍未解决。为了解决这一问题,又引入了内网映射的技术,以解决 IP 数量不足的问题。

IPv4 地址分为公网地址(万维网使用)和私有地址(局域网使用)。

10.X.X.X, 172.16.X.X -> 172.31.X.X, 192.168.X.X 是私有地址,专为组织机构内部使用。路由器持有一个公网 IP,多台计算机或其他联网设备连接上同一台路由器后,由路由器分配其局域网中所有设备的局域网 IP 地址。

注意一个特殊的 IP 地址 127.0.0.1localhost),称为回送地址本地回环地址,也称本机 IP,永远只会寻找当前主机。同一台设备连接上不同的局域网时,其所获得的 IP 地址可能不同。使用回送地址,数据经过网卡时在网卡直接返回而不经过路由器,表示当前主机,防止由于 IP 地址发生变化导致寻址出错或失败。因此建议在自己练习时使用该地址 127.0.0.1

此外,还有一些特殊的 IP 地址。IP 0.0.0.0 表示整个网络,即网络中的所有主机。其作用是帮助路由器发送路由表中无法查询的包。

IPv6 地址:全称为 Internet Protocol version 6,网络协议第六版。

IPv6 使用 128 位(16 字节)二进制数字串唯一标识一台联网设备在网络中的地址。通常使用冒分十六进制表示法,将 128 分成 8 组十六进制数字(无负值),每一个数字用冒号分隔。

00100000 00000001 00001101 10111000 00000000 00000000 00000000 00100011
00000000 00001000 00001000 00000000 00100000 00001100 01000001 01111010
-> 2001:0DB8:0000:0023:0008:0800:200C:417A -> 2001:0DB8:0:23:8:800:200C:417A
FF01:0:0:0:0:0:0:1101 -> FF01::1101

现在有越来越多的服务支持 IPv6 技术,IPv6 将成为未来的主流 IP 分配方案。IPv6 所能分配的 IP 地址的总个数为 \(2^{128} \approx = 3.4 \times 10^{38}\)

常用 CMD 命令:使用 ipconfig 可查看本机 IP 地址,ping 可检查网络是否畅通。

6-1.3 InetAddress

InetAddress 位于 java.net 下,表示一个网络协议(IP)地址。

自 JDK 21 起,InetAddress 成为了一个封闭类,仅允许 Inet4AddressInet6Address 作为其子类,表示对应版本的网络协议。这些子类同时也被声明为 final 类,不允许继承。

静态方法:由于该类的构造器私有化,若要创建其实例,应当通过其静态方法返回实例。

静态方法 描述
InetAddress getByName(String host) 根据主机的名字,决定主机的 IP 地址
InetAddress getByAddress(byte[] addr) 根据原始 IP 地址返回 InetAddress 对象
InetAddress getLocalHost() 返回本地主机的 IP 地址
InetAddress getLoopbackAddress() 返回回送地址

注意

  1. getByName(String) 方法:参数 host 可以是 IP 地址,也可以是机器名称,例如 www.example.com

  2. getByAddress(byte[]) 方法:参数排列顺序同 IP 地址排列顺序,高位在数组的 0 索引处;且注意,Java 没有无符号字节类型,byte 的取值范围为 -128, 127,使得 IP 地址大于 127 的字节部分会溢出,显示为负值;

    IPv4 地址的字节数组长度必须为 4,IPv6 地址的字节数组长度必须为 16;

  3. getLocalHost() 方法:方法返回本地主机在当前网络中的地址;

  4. getLoopbackAddress() 方法:方法返回的地址永远指向本地回环地址 127.0.0.1

成员方法

方法 描述
byte[] getAddress() 返回该对象的原始 IP 地址
String getHostAddress() 返回该对象 IP 地址的文本表示
String getHostName() 返回该 IP 地址的主机名称
String getCanonicalHostName() 返回该 IP 地址的完全限定域名(FQDN)

注意

  1. getAddress() 方法:方法返回对象的原始 IP 地址,字节按照原始 IP 的顺序存储,高位在前,将不同字节分割为数组元素,便于分开处理;

    由于 Java 没有无符号字节型,因此超出 127 的字节将会溢出显示为负值。若要正常显示,还需要转型成 intlong,方法为

    InetAddress baidu = InetAddress.getByName("www.baidu.com");	// 110.242.68.3
    
    byte[] addr = baidu.getAddress();	// [110, -14, 68, 3],有溢出
    int[] fixedAddr = new int[addr.length];
    for (int i = 0; i < fixedArr.length; i++) {
        int segment = addr[i];
        fixedArr[i] = (segment < 0) ? (256 + segment) : segment;
    }
    

    最终可获得正确结果;

  2. getHostName() 方法:返回主机的名称,但若无法连接到指定主机时返回 IP 地址;

  3. getCanonicalHostName() 方法:返回主机的完全限定域名(FQDN),但若无法连接到主机时会返回 IP 地址;

6-1.4 端口号

端口号是应用程序在设备中的唯一标识。

端口号是一个由两个字节表示的整数,范围为 0 ~ 65535。其中,01023 之间的端口号被用于一些知名的网络服务或应用。因此,我们自己使用 1024 及以上的端口即可。

注意,一个端口号只能够被一个应用程序所使用,若有第二个或多个应用程序试图绑定同一个端口,则会发生端口冲突。

6-1.5 网络层协议

计算机网络中,连接和通信的规则称为网络通信协议。

网络通信协议有许多种,多个通信协议的组合构成一个通信协议模型。有两种通信协议模型:

  • 开放系统互联模型(Open Systems Interconnection Model, OSI):由国际标准化组织(ISO)定义的一个概念化模型。

    OSI 参考模型将系统间的通信划分为七个不同的抽象层,从低到高分别为物理层(Physical)、数据链路层(Data Link)、网络层(Network)、传输层(Transport)、会话层(Session)、表示层(Presentation)和应用层(Application)。由于这个模型过于理想化,实际上并没有采用这种模型。

  • 互联网协议族(Internet Protocol Suite):通常称为 TCP/IP 协议(TCP/IP),即传输控制协议(Transmission Control Protocol)和网际协议(Internet Protocol),这两个协议是协议套件中的两个基础协议,另一个基础协议是用户数据报协议(User Datagram Protocol)。

    该模型具有四层(有时也为五层,将最后一层拆分为两层),从低到高依次为物理-链路层(Physical-Link)、网络层(Network)、传输层(Transport)和应用层(Application)。每一层都有不同的协议,负责不同的工作。网络中不同的设备在一个或多个不同的层中工作。

    常见的应用层协议有文件传输协议(FTP)、超文本传输协议(HTTP)、简单邮件传输协议(SMTP)、域名解析服务(DNS)等,传输层协议有 TCP 和 UDP,网络层协议有 IP、互联网控制消息协议(ICMP)、地址解析协议(ARP)等,而在数据链路层,数据以比特流的形式在硬件间通过介质传输,没有配备的协议。

如今,所有设备采用的都是 TCP/IP 协议联网。这里先简单地了解传输层的两种不同协议。

  • 传输控制协议(TCP):面向有连接的协议,速度慢,无大小限制,数据安全;
  • 用户数据报协议(UDP):面向无连接的协议,速度快,具有大小限制(最多 64 KB),数据不安全,易丢失;

两种协议都具有应用场景。UDP 适用于速度要求高的场景中,例如网络会议、语音通话、在线视频等;TCP 适用于数据完整性要求高的场景中,例如文件下载、文字聊天、发送邮件等。

6-1.5.1 简易 UDP 单播聊天室

示例:一个简单的 UDP 聊天室。

使用流程:使用 UDP 创建一个聊天室,需要将聊天室分为发送端和接收端。对于发送端,首先需要有使用了 UDP 的数据报套接字对象(DatagramSocket),然后,再将数据以字节的形式(byte[])打包到一个数据报文包(DatagramPacket)中,并指定接收地址、接收端口,随后,再通过套接字发送该数据包。一旦套接字不再使用,调用 close 方法关闭流以释放资源。

对于接收端,同样也需要先创建数据报套接字对象,但不同的是,创建时需要指定接收端口,接收端口需要同发送时指定的端口一致。然后,通过数据包的形式接收数据,接收到的数据经过解析可得到数据字节(byte[])、发送方的 IP 地址、数据长度等信息。解析得到的数据可以打印到屏幕上供用户查看。一旦使用结束,关闭流以释放资源。

// 发送端的 Java 示例程序
import java.io.IOException;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.util.Scanner;

public class UDPChatSender {
    public static void main(String[] args) throws IOException {
        // 创建一个 DatagramSocket 对象
        // 参数留空将随机抽取一个可用端口使用
        DatagramSocket ds = new DatagramSocket();
        // 提示语句
        Scanner scanner = new Scanner(System.in);
        String user = InetAddress.getLocalHost().getHostName();
        String chat = "";

        // 循环体反复接受键盘输入并发送数据,键入 "/stop" 以停止
        while (true) {
            System.out.print(user + ": ");
            chat = scanner.nextLine();

            if (chat.equals("/stop")) break;

            // 转换成字节数据
            byte[] buffer = chat.getBytes(StandardCharsets.UTF_8);

            // 将字节数据打包,指定发送端口和发送地址(IP)
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length, InetAddress.getByName("127.0.0.1"), 9328);
            ds.send(packet);
        }

        // 关闭流,释放资源
        scanner.close();
        ds.close();
    }
}
// 接收端的 Java 示例程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class UDPChatReceiver {
    // 指定缓存字节数组的长度
    private static final int BUFFER_LENGTH = 1024;

    public static void main(String[] args) throws IOException {
        // 创建一个 DatagramSocket 对象,指定接收端口,必须同发送时指定的端口一致
        DatagramSocket ds = new DatagramSocket(9328);

        // 循环接收数据
        while (true) {
            // 创建数据报文
            DatagramPacket packet = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
            // 用数据报文接收数据
            // 该方法是阻塞方法,会一直等待到接收到数据为止
            ds.receive(packet);

            // 解析报文字节数据、发送方 IP、主机名
            byte[] bytes = packet.getData();
            // 打印输出
            System.out.println(packet.getAddress().getHostName() + ": " + new String(bytes, 0, packet.getLength()));
        }
    }
}

注意:示例程序中的异常采用了对外抛出处理,而如果需要采用 try-catch 语句块环绕处理,其处理方法同 IO 流中的异常处理方法。

接下来我们来看看两种不同的传输层协议的通信方式有何不同。

6-1.5.2 UDP 的三种通信方式

UDP 有三种通信方式,分别为单播(unicast)、组播(multicast)和广播(broadcast)。前文的示例程序采用的是单播方式进行通信。

  • 单播:对特定的主机传输数据,即一台机器和另一台机器通信;
  • 组播:对特定的一组主机传输数据,是广播的特例,采用 D 类 IP 地址 224.0.0.0 ~ 239.255.255.255 作为预留的组播地址;
  • 广播:对某一网络中所有的主机发送数据包。广播地址为 255.255.255.255(E 类),对于不同网络或子网而言,其广播地址不同。

示例:一个简单的组播发送和接收程序。

在 Java 中使用组播通信,使用方法和流程和单播相似,区别在于使用的套接字对象不同。在组播当中,套接字对象使用的是 MulticastSocket,其继承于 DatagramSocket,该类的使用方法和 Datagram 相同。对于发送方而言,可以不需要考虑添加到某个组播地址中。而对于接收方,需要调用独有的 joinGroup 方法将自己添加到某个组播地址中,而离开某个分组只需要调用 leaveGroup 方法即可。

注意,使用组播时,使用的地址必须要在组播地址范围内,否则会抛出异常。

// 组播发送端示例程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.MulticastSocket;
import java.nio.charset.StandardCharsets;

public class MulticastSender {
    public static void main(String[] args) throws IOException {
        // UDP 组播方式发送数据
        // 1. 创建组播套接字对象
        MulticastSocket socket = new MulticastSocket();

        // 2. 创建数据报文
        byte[] buff = "你好你好!".getBytes(StandardCharsets.UTF_8);
        DatagramPacket packet = new DatagramPacket(buff, buff.length, InetAddress.getByName("224.0.0.2"), 9328);

        // 3. 使用套接字发送数据
        socket.send(packet);

        // 4. 关闭流,释放资源
        socket.close();
    }
}
// 组播接收端示例程序
import java.io.IOException;
import java.net.*;

public class MulticastReceiver {
    private static final int BUFFER_LENGTH = 1024;

    public static void main(String[] args) throws IOException {
        // 使用 UDP 接收数据
        // 1. 创建组播套接字对象
        MulticastSocket socket = new MulticastSocket(9328);

        // 2. 将接收端主机添加到组播中
        InetAddress address = InetAddress.getByName("224.0.0.2");
        socket.joinGroup(new InetSocketAddress(address, 9328), NetworkInterface.getByInetAddress(address));

        // 3. 创建数据报文接收数据
        DatagramPacket packet = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
        socket.receive(packet);

        //4. 解析数据
        System.out.println(packet.getAddress().getHostName() + ": " + new String(packet.getData(), 0, packet.getLength()));

        // 4. 关闭流,释放资源
        socket.close();
    }
}

示例:一个简单的广播发送和接收程序。

使用广播的通信方式和单播相同,只需要将发送端的目标地址改为 255.255.255.255 即可。

// 广播发送端示例程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.nio.charset.StandardCharsets;

public class BroadcastSender {
    public static void main(String[] args) throws IOException {
        // 使用 UDP 广播的发送端
        // 1. 创建套接字对象
        DatagramSocket socket = new DatagramSocket();

        // 2. 创建报文
        byte[] buff = "你好你好!".getBytes(StandardCharsets.UTF_8);
        DatagramPacket packet = new DatagramPacket(buff, buff.length, InetAddress.getByName("255.255.255.255"), 9328);

        // 3. 发送数据
        socket.send(packet);

        // 4. 关闭流,释放资源
        socket.close();
    }
}
// 广播接收端示例程序
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;

public class BroadcastReceiver {
    private static final int BUFFER_LENGTH = 1024;

    public static void main(String[] args) throws IOException {
        // 使用 UDP 广播的接收端
        // 1. 创建套接字对象
        DatagramSocket socket = new DatagramSocket(9328);

        // 2. 创建报文
        DatagramPacket packet = new DatagramPacket(new byte[BUFFER_LENGTH], BUFFER_LENGTH);
        socket.receive(packet);

        // 3. 解析数据
        System.out.println(packet.getAddress().getHostName() + ": " + new String(packet.getData(), 0, packet.getLength()));

        socket.close();
    }
}

6-1.5.3 TCP 通信、三次握手与四次挥手

TCP 通信协议是一种可靠的网络协议,它在通信的两端各建立一个 Socket 对象。通信之前需要保证连接建立,通过 Socket 产生 IO 流进行通信。

TCP 通信没有 UDP 中的单播、组播、广播的概念。

使用流程:建立 TCP 通信时,客户端需要一个 Socket 对象,指向服务器的 IP 地址和端口,使用输出流向服务器发送数据;服务端需要一个 ServerSocket 对象,指定通信端口(需要保证客户端和服务端的端口一致),调用 accept 方法获取建立连接的客户端 Socket 对象,再通过该对象的输入流读取发送的数据。所有的流都是字节流。

示例:一个简单的 TCP 客户端-服务端通信。

// TCP 客户端示例程序
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetAddress;
import java.net.Socket;

public class Client {
    public static void main(String[] args) throws IOException {
        // TCP 客户端
        // 1. 创建一个 Socket 对象
        // 无连接则会抛出异常
        Socket client = new Socket(InetAddress.getByName("127.0.0.1"), 9328);

        // 2. 使用输出流发送数据
        OutputStream os = client.getOutputStream();
        os.write("你好你好!".getBytes());

        // 3. 关闭流,释放资源
        os.close();
        client.close();
    }
}
// TCP 服务端示例程序
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

public class Server {
    public static void main(String[] args) throws IOException {
        // TCP 服务器端
        // 1. 创建一个 ServerSocket 对象
        ServerSocket server = new ServerSocket(9328);

        // 2. 等待客户端连接(阻塞),有连接时返回一个 Socket 对象
        Socket socket = server.accept();

        // 3. 使用输入流读取数据,使用转换流包装以消除乱码
        InputStreamReader reader = new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8);

        // 4. 读取数据
        char[] chars = new char[512];
        StringBuilder sb = new StringBuilder();
        int b = 0;
        while ((b = reader.read(chars)) != -1) {
            sb.append(chars, 0, b);
        }
        System.out.println(sb);

        // 5. 关闭流,释放资源
        socket.close();
        server.close();
    }
}

注意:由于 TCP 协议是一个面向有连接的协议,Socket 对象在初始化时会检查是否能够建立连接,若不能则会抛出异常 ConnectException。因此,测试时应当先开启服务端进程。

细节

  1. IO 流充当了客户端和服务端之间通信的通道,数据在这条通道间进行传输。读写数据都需要通过 Socket 对象中的流进行,对于客户端而言,它向服务端发送数据,因此为输出流;对于服务端,它接收客户端发送的数据,因此为输入流。一旦 Socket 被关闭,其包含的流也会一并被关闭。
  2. TCP 协议通过网络 IO 流进行通信。
  3. 值得注意的是,服务端获取客户端的连接套接字 Socket 对象及其对应的流后,以循环方式调用 read 方法,方法会不断地以阻塞方式尝试读取来自客户端的数据。为防止程序卡在循环处,客户端发送完数据后应当调用 shutdownOutput() 方法关闭流(这并不会关闭套接字)。一个套接字只有一个对应的输入输出流,一旦关闭则不可再使用。

下面简要地了解 TCP 的通信程序。

三次握手:建立通信时的三次确认步骤,确保连接建立。

  1. 客户端先向服务器发送连接请求,等待服务器确认;
  2. 服务器向客户端返回响应,告诉客户端收到了请求;
  3. 客户端再次向服务器发送确认消息,连接建立。

四次握手:用于通信取消时数据通道的处理,确保连接断开,且数据处理完毕。

  1. 客户端向服务器发送取消链接请求;
  2. 服务器向客户端返回响应,告诉客户端收到了请求;
  3. 服务器会将未处理的数据处理完毕,然后向客户端发送确认取消信息;
  4. 客户端再次发送确认消息,连接断开。

6-1.X 参考

一图看完8种常用网络协议 - ByteByteGo(微信公众平台)

图解网络介绍 | 小林coding (xiaolincoding.com)

几个特殊的IP地址_特殊ip地址有哪些-CSDN博客

单播、广播、多播(组播)的概念和区别_多播和组播的区别-CSDN博客

IP地址的分类及范围详解:A、B、C、D、E五类是如何划分的_a类地址-CSDN博客

广播地址、组播地址、网关和子网掩码-CSDN博客

posted @ 2024-01-28 00:38  Zebt  阅读(33)  评论(0)    收藏  举报