java 基础--网络编程

目录

一 基础

1.1 IP 地址

1.2 域名

1.3 网络模型

1.4 常用协议

二 TCP 编程

2.1 服务器端

2.2 客户端

2.3 Socket 流

三 UDP 编程

3.1 服务器端

3.2 客户端

四 发送 email

4.1 准备 SMTP 登录信息

4.2 发送电子邮件

4.3 发送 HTML 邮件

4.4 发送附件

4.5 发送内嵌图片的 HTML 邮件

4.6 常见问题

五 接收 email

六 HTTP 编程

七 RMI 远程调用


一 基础

在学习 java 网络编程之前,先来了解一下什么是计算机网络。

计算机网络:是指两台或更多的计算机组成的网络,在同一个网络中,任意两台计算机都可以直接通信,因为所有计算机都需要遵循同一种网络协议。

互联网(internet):是把很多计算机网络连接起来,形成一个全球统一的互联网。

对某个特定的计算机网络来说,它可能使用网络协议 ABC,而另一个计算机网络可能使用网络协议 XYZ。如果计算机网络各自的通讯协议不统一,就没法把不同的网络连接起来形成互联网。因此,为了把计算机网络接入互联网,就必须使用 TCP/IP 协议。

TCP/IP 协议泛指互联网协议,其中最重要的两个协议是 TCP 协议和 IP 协议。只有使用 TCP/IP 协议的计算机才能够联入互联网,使用其他网络协议(如:NetBIOS、AppleTalk 协议等)是无法联入互联网的。

1.1 IP 地址

在互联网中,一个 IP 地址用于唯一标识一个网络接口(Network Interface)。一台联入互联网的计算机肯定有一个 IP 地址,但也可能有多个 IP 地址。

IP 地址分为 IPv4 和 IPv6 两种。IPv4 采用 32 位地址,类似 101.202.99.12,而 IPv6 采用 128 位地址,类似 2001:0DA8:100A:0000:0000:1020:F2F3:1428。IPv4 地址总共有 232 个(大约 42 亿),而 IPv6 地址则总共有 2128 个(大约 340 万亿亿亿亿),IPv4 的地址目前已耗尽,而 IPv6 的地址是基本用不完的。

IP 地址又分为公网 IP 地址内网 IP 地址。公网 IP 地址可以直接被访问,内网 IP 地址只能在内网访问。内网 IP 地址类似于:

  • 192.168.x.x
  • 10.x.x.x

有一个特殊的 IP 地址,称之为本机地址,它总是 127.0.0.1

IPv4 地址实际上是一个 32 位整数。例如:

1707762444 = 0x65ca630c
           = 65  ca  63 0c
           = 101.202.99.12

如果一台计算机只有一个网卡,并且接入了网络,那么,它有一个本机地址 127.0.0.1,还有一个 IP 地址,例如 101.202.99.12,可以通过这个 IP 地址接入网络。

如果一台计算机有两块网卡,那么除了本机地址,它可以有两个 IP 地址,可以分别接入两个网络。通常连接两个网络的设备是路由器或者交换机,它至少有两个 IP 地址,分别接入不同的网络,让网络之间连接起来。

如果两台计算机位于同一个网络,那么他们之间可以直接通信,因为他们的 IP 地址前段是相同的,也就是网络号是相同的。网络号是 IP 地址通过子网掩码过滤后得到的。例如:

某台计算机的 IP 是 101.202.99.2,子网掩码是 255.255.255.0,那么计算该计算机的网络号是:

IP = 101.202.99.2
Mask = 255.255.255.0
Network = IP & Mask = 101.202.99.0

每台计算机都需要正确配置 IP 地址和子网掩码,根据这两个就可以计算网络号,如果两台计算机计算出的网络号相同,说明两台计算机在同一个网络,可以直接通信。如果两台计算机计算出的网络号不同,那么两台计算机不在同一个网络,不能直接通信,它们之间必须通过路由器或者交换机这样的网络设备间接通信,我们把这种设备称为网关

网关的作用就是连接多个网络,负责把来自一个网络的数据包发到另一个网络,这个过程叫路由

所以,一台计算机的一个网卡会有 3 个关键配置:

  • IP地址,例如:10.0.2.15
  • 子网掩码,例如:255.255.255.0
  • 网关的IP地址,例如:10.0.2.2

1.2 域名

因为直接记忆 IP 地址非常困难,所以我们通常使用域名访问某个特定的服务。域名解析服务器 DNS 负责把域名翻译成对应的 IP,客户端再根据 IP 地址访问服务器。

用 nslookup 可以查看域名对应的 IP 地址:

$ nslookup www.baidu.com
Server:  xxx.xxx.xxx.xxx
Address: xxx.xxx.xxx.xxx#53

Non-authoritative answer:
Name:    www.baidu.com
Address: 36.152.44.96

有一个特殊的本机域名 localhost,它对应的 IP 地址总是本机地址 127.0.0.1

1.3 网络模型

由于计算机网络从底层的传输到高层的软件设计十分复杂,要合理地设计计算机网络模型,必须采用分层模型,每一层负责处理自己的操作。OSI(Open System Interconnect)网络模型是 ISO 组织定义的一个计算机互联的标准模型,注意它只是一个定义,目的是为了简化网络各层的操作,提供标准接口便于实现和维护。这个模型从上到下依次是:

  • 应用层,提供应用程序之间的通信;
  • 表示层:处理数据格式,加解密等等;
  • 会话层:负责建立和维护会话;
  • 传输层:负责提供端到端的可靠传输;
  • 网络层:负责根据目标地址选择路由来传输数据;
  • 链路层和物理层负责把数据进行分片并且真正通过物理网络传输,例如,无线网、光纤等。

互联网实际使用的 TCP/IP 模型并不是对应到 OSI 的 7 层模型,而是大致对应 OSI 的 5 层模型:

OSITCP/IP
应用层应用层
表示层
会话层
传输层传输层
网络层IP 层
链路层网络接口层
物理层

1.4 常用协议

IP 协议是一个分组交换,它不保证可靠传输。而 TCP 协议是传输控制协议,它是面向连接的协议,支持可靠传输和双向通信。TCP 协议是建立在 IP 协议之上的,简单地说,IP 协议只负责发数据包,不保证顺序和正确性,而 TCP 协议负责控制数据包传输,它在传输数据之前需要先建立连接,建立连接后才能传输数据,传输完后还需要断开连接。TCP 协议之所以能保证数据的可靠传输,是通过接收确认、超时重传这些机制实现的。并且,TCP 协议允许双向通信,即通信双方可以同时发送和接收数据。

TCP 协议也是应用最广泛的协议,许多高级协议都是建立在 TCP 协议之上的,例如 HTTP、SMTP 等。

UDP 协议(User Datagram Protocol)是一种数据报文协议,它是无连接协议,不保证可靠传输。因为 UDP 协议在通信前不需要建立连接,因此它的传输效率比 TCP 高,而且 UDP 协议比 TCP 协议要简单得多。

选择 UDP 协议时,传输的数据通常是能容忍丢失的,例如,一些语音视频通信的应用会选择 UDP 协议。

二 TCP 编程

在开发网络应用程序的时候,又会遇到 Socket 这个概念。Socket 是一个抽象概念,一个应用程序通过一个 Socket 来建立一个远程连接,而 Socket 内部通过 TCP/IP 协议把数据传输到网络:

Socket、TCP 和部分 IP 的功能都是由操作系统提供的,不同的编程语言只是提供了对操作系统调用的简单的封装。例如,Java 提供的几个 Socket 相关的类就封装了操作系统提供的接口。

为什么需要 Socket 进行网络通信?因为仅仅通过 IP 地址进行通信是不够的,同一台计算机同一时间会运行多个网络应用程序,例如浏览器、QQ、邮件客户端等。当操作系统接收到一个数据包的时候,如果只有 IP 地址,它没法判断应该发给哪个应用程序,所以,操作系统抽象出 Socket 接口,每个应用程序需要各自对应到不同的 Socket,数据包才能根据 Socket 正确地发到对应的应用程序。

 一个 Socket 就是由 IP 地址和端口号(范围是0~65535)组成,可以把 Socket 简单理解为 IP 地址加端口号。端口号总是由操作系统分配,它是一个 0~65535 之间的数字,其中,小于 1024 的端口属于特权端口,需要管理员权限,大于 1024 的端口可以由任意用户的应用程序打开。

使用 Socket 进行网络编程时,本质上就是两个进程之间的网络通信。其中一个进程必须充当服务器端,它会主动监听某个指定的端口,另一个进程必须充当客户端,它必须主动连接服务器的 IP 地址和指定端口,如果连接成功,服务器端和客户端就成功地建立了一个 TCP 连接,双方后续就可以随时发送和接收数据。

因此,当 Socket 连接成功地在服务器端和客户端之间建立后:

  • 对服务器端来说,它的 Socket 是指定的 IP 地址和指定的端口号;
  • 对客户端来说,它的 Socket 是它所在计算机的 IP 地址和一个由操作系统分配的随机端口号。

2.1 服务器端

要使用 Socket 编程,首先要编写服务器端程序。Java 标准库提供了 ServerSocket 来实现对指定 IP 和指定端口的监听。ServerSocket 的实现代码如下:

public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocket ss = new ServerSocket(6666); // 监听指定端口
        System.out.println("server is running...");
        for (;;) {
            Socket sock = ss.accept();
            System.out.println("connected from " + sock.getRemoteSocketAddress());
            Thread t = new Handler(sock);
            t.start();
        }
    }
}

class Handler extends Thread {
    Socket sock;

    public Handler(Socket sock) {
        this.sock = sock;
    }

    @Override
    public void run() {
        try (InputStream input = this.sock.getInputStream()) {
            try (OutputStream output = this.sock.getOutputStream()) {
                handle(input, output);
            }
        } catch (Exception e) {
            try {
                this.sock.close();
            } catch (IOException ioe) {
            }
            System.out.println("client disconnected.");
        }
    }

    private void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        writer.write("hello\n");
        writer.flush();
        for (;;) {
            String s = reader.readLine();
            if (s.equals("bye")) {
                writer.write("bye\n");
                writer.flush();
                break;
            }
            writer.write("ok: " + s + "\n");
            writer.flush();
        }
    }
}

服务器端指定端口 6666 监听。这里没有指定 IP 地址,表示在计算机的所有网络接口上进行监听。如果 ServerSocket 监听成功,就使用一个无限循环来处理客户端的连接。

注意到代码 ss.accept() 表示每当有新的客户端连接进来后,就返回一个 Socket 实例,这个 Socket 实例就是用来和刚连接的客户端进行通信的。由于客户端很多,要实现并发处理,就必须为每个新的 Socket 创建一个新线程来处理,这样,主线程的作用就是接收新的连接,每当收到新连接后,就创建一个新线程进行处理。

这里也可以利用线程池来处理客户端连接,能大大提高运行效率。

如果没有客户端连接进来,accept() 方法会阻塞并一直等待。如果有多个客户端同时连接进来, ServerSocket 会把连接扔到队列里,然后一个一个处理。对于 Java 程序而言,只需要通过循环不断调用 accept() 就可以获取新的连接。

2.2 客户端

客户端程序如下:

public class Client {
    public static void main(String[] args) throws IOException {
        Socket sock = new Socket("localhost", 6666); // 连接指定服务器和端口
        try (InputStream input = sock.getInputStream()) {
            try (OutputStream output = sock.getOutputStream()) {
                handle(input, output);
            }
        }
        sock.close();
        System.out.println("disconnected.");
    }

    private static void handle(InputStream input, OutputStream output) throws IOException {
        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));
        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
        Scanner scanner = new Scanner(System.in);
        System.out.println("[server] " + reader.readLine());
        for (;;) {
            System.out.print(">>> "); // 打印提示
            String s = scanner.nextLine(); // 读取一行输入
            writer.write(s);
            writer.newLine();
            writer.flush();
            String resp = reader.readLine();
            System.out.println("<<< " + resp);
            if (resp.equals("bye")) {
                break;
            }
        }
    }
}

注意上述代码的服务器地址是 "localhost",表示本机地址,端口号是 6666。如果连接成功,将返回一个 Socket 实例,用于后续通信。

2.3 Socket 流

当 Socket 连接创建成功后,无论是服务器端,还是客户端,都使用 Socket 实例进行网络通信。因为 TCP 是一种基于流的协议,因此,Java 标准库使用 InputStreamOutputStream 来封装 Socket 的数据流,这样我们使用 Socket 的流,和普通 IO 流类似:

// 用于读取网络数据:
InputStream in = sock.getInputStream();
// 用于写入网络数据:
OutputStream out = sock.getOutputStream();

最后重点来看看,为什么写入网络数据时,要调用 flush() 方法。

如果不调用 flush(),客户端和服务器很可能都收不到数据,这并不是 Java 标准库的设计问题,而是以流的形式写入数据的时候,并不是一写入就立刻发送到网络,而是先写入内存缓冲区,直到缓冲区满了以后,才会一次性真正发送到网络,这样设计的目的是为了提高传输效率。如果缓冲区的数据很少,而我们又想强制把这些数据发送到网络,就必须调用 flush() 强制把缓冲区数据发送出去。

三 UDP 编程

和 TCP 编程相比,UDP 编程就简单得多,因为 UDP 没有创建连接,数据包也是一次收发一个,所以没有流的概念。

在 Java 中使用 UDP 编程,仍然需要使用 Socket,因为应用程序在使用 UDP 时必须指定网络接口(IP)和端口号。注意:UDP 端口和 TCP 端口虽然都使用 0~65535,但他们是两套独立的端口,即一个应用程序用 TCP 占用了端口 1234,不影响另一个应用程序用 UDP 占用端口 1234。

3.1 服务器端

在服务器端,使用 UDP 也需要监听指定的端口。Java 提供了 DatagramSocket 来实现这个功能,代码如下:

DatagramSocket ds = new DatagramSocket(6666); // 监听指定端口
for (;;) { // 无限循环
    // 数据缓冲区:
    byte[] buffer = new byte[1024];
    DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
    ds.receive(packet); // 收取一个UDP数据包
    // 收取到的数据存储在buffer中,由packet.getOffset(), packet.getLength()指定起始位置和长度
    // 将其按UTF-8编码转换为String:
    String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);
    // 发送数据:
    byte[] data = "ACK".getBytes(StandardCharsets.UTF_8);
    packet.setData(data);
    ds.send(packet);
}

服务器端首先使用如下语句在指定的端口监听 UDP 数据包:

DatagramSocket ds = new DatagramSocket(6666);

如果没有其他应用程序占据这个端口,那么监听成功,这里使用一个无限循环来处理收到的 UDP 数据包:

要接收一个 UDP 数据包,需要准备一个 byte[] ​​​​​​​缓冲区,并通过 ​​​​​​​DatagramPacket 实现接收:

byte[] buffer = new byte[1024];
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet);

假设我们收取到的是一个 String,那么,通过 DatagramPacket 返回的 packet.getOffset() 和 packet.getLength() 确定数据在缓冲区的起止位置:

String s = new String(packet.getData(), packet.getOffset(), packet.getLength(), StandardCharsets.UTF_8);

当服务器收到一个 DatagramPacket 后,通常必须立刻回复一个或多个 UDP 包,因为客户端地址在 DatagramPacket 中,每次收到的 DatagramPacket 可能是不同的客户端,如果不回复,客户端就收不到任何 UDP 包。

发送 UDP 包也是通过 ​​​​​​​DatagramPacket 实现的,发送代码非常简单:

byte[] data = ...
packet.setData(data);
ds.send(packet);

3.2 客户端

和服务器端相比,客户端使用 UDP 时,只需要直接向服务器端发送 UDP 包,然后接收返回的 UDP 包:

DatagramSocket ds = new DatagramSocket();
ds.setSoTimeout(1000);
ds.connect(InetAddress.getByName("localhost"), 6666); // 连接指定服务器和端口
// 发送:
byte[] data = "Hello".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length);
ds.send(packet);
// 接收:
byte[] buffer = new byte[1024];
packet = new DatagramPacket(buffer, buffer.length);
ds.receive(packet);
String resp = new String(packet.getData(), packet.getOffset(), packet.getLength());
ds.disconnect();

客户端创建 DatagramSocket 实例时并不需要指定端口,而是由操作系统自动指定一个当前未使用的端口。调用 setSoTimeout(1000) 设定超时1秒,意思是后续接收 UDP 包时,等待时间最多不会超过1秒,否则在没有收到 UDP 包时,客户端会无限等待下去。这一点和服务器端不一样,服务器端可以无限等待,因为它本来就被设计成长时间运行。

注意到客户端的 DatagramSocket 还调用了一个 connect() 方法“连接”到指定的服务器端。不是说 UDP 是无连接的协议吗?为啥这里需要 connect()

这个 connect() 方法不是真连接,它是为了在客户端的 DatagramSocket 实例中保存服务器端的 IP 和端口号,确保这个 DatagramSocket 实例只能往指定的地址和端口发送 UDP 包,不能往其他地址和端口发送。这么做不是 UDP 的限制,而是 Java 内置了安全检查。

如果客户端希望向两个不同的服务器发送 UDP 包,那么它必须创建两个 DatagramSocket 实例。

后续的收发数据和服务器端是一致的。客户端必须先发 UDP 包,因为客户端不发 UDP 包,服务器端就根本不知道客户端的地址和端口号。

如果客户端认为通信结束,就可以调用 disconnect() 断开连接。

注意到 disconnect() 也不是真正地断开连接,它只是清除了客户端 DatagramSocket 实例记录的远程服务器地址和端口号,这样,DatagramSocket 实例就可以连接另一个服务器端。

四 发送 email

使用 Java 程序也可以收发电子邮件。先来看一下传统的邮件是如何发送的。

传统的邮件是通过邮局投递,然后从一个邮局到另一个邮局,最终到达用户的邮箱。

电子邮件的发送过程也是类似的,只不过是电子邮件是从用户电脑的邮件软件,例如 Outlook,发送到邮件服务器上,可能经过若干个邮件服务器的中转,最终到达对方邮件服务器上,收件方就可以用软件接收邮件:

 

我们把类似 Outlook 这样的邮件软件称为 MUA(Mail User Agent):意思是给用户服务的邮件代理;邮件服务器则称为 MTA(Mail Transfer Agent):意思是邮件中转的代理;最终到达的邮件服务器称为 MDA(Mail Delivery Agent):意思是邮件到达的代理。电子邮件一旦到达  MDA,就不再动了。实际上,电子邮件通常就存储在  MDA 服务器的硬盘上,然后等收件人通过软件或者登陆浏览器查看邮件。

MTA 和 MDA 这样的服务器软件通常是现成的,我们不关心这些服务器内部是如何运行的。要发送邮件,我们关心的是如何编写一个 MUA 的软件,把邮件发送到 MTA 上。

MUA 到 MTA 发送邮件的协议就是 SMTP(Simple Mail Transport Protocol) 协议,使用标准端口25,也可以使用加密端口 465 或 587。

SMTP 协议是一个建立在 TCP 之上的协议,任何程序发送邮件都必须遵守 SMTP 协议。使用 Java 程序发送邮件时,我们无需关心 SMTP 协议的底层原理,只需要使用 JavaMail 这个标准 API 就可以直接发送邮件。

4.1 准备 SMTP 登录信息

假设我们准备使用自己的邮件地址 me@example.com 给小明发送邮件,已知小明的邮件地址是 xiaoming@somewhere.com,发送邮件前,首先要确定作为 MTA 的邮件服务器地址和端口号。邮件服务器地址通常是 smtp.example.com,端口号由邮件服务商确定使用 25、465 还是 587。以下是一些常用邮件服务商的 SMTP 信息:

  • QQ 邮箱:SMTP 服务器是 smtp.qq.com,端口是 465/587;
  • 163 邮箱:SMTP 服务器是 smtp.163.com,端口是 465;
  • Gmail 邮箱:SMTP 服务器是 smtp.gmail.com,端口是 465/587。

有了 SMTP 服务器的域名和端口号,还需要 SMTP 服务器的登录信息,通常是使用自己的邮件地址作为用户名,登录口令是用户口令或者一个独立设置的 SMTP 口令。

来看看如何使用 JavaMail 发送邮件。

首先,需要创建一个 Maven 工程,并把 JavaMail 相关的两个依赖加入进来:

  • jakarta.mail:javax.mail-api:2.0.1
  • com.sun.mail:jakarta.mail:2.0.1

这两个包一个是接口定义,一个是具体实现。如果使用早期的1.x版本,则需注意引入的包名有所不同:

  • javax.mail:javax.mail-api:1.6.2
  • com.sun.mail:javax.mail:1.6.2

并且代码引用的 jakarta.mail 需替换为 javax.mail

然后,我们通过 JavaMail API 连接到 SMTP 服务器上:

// 服务器地址:
String smtp = "smtp.office365.com";
// 登录用户名:
String username = "jxsmtp101@outlook.com";
// 登录口令:
String password = "********";
// 连接到SMTP服务器587端口:
Properties props = new Properties();
props.put("mail.smtp.host", smtp); // SMTP主机名
props.put("mail.smtp.port", "587"); // 主机端口号
props.put("mail.smtp.auth", "true"); // 是否需要用户认证
props.put("mail.smtp.starttls.enable", "true"); // 启用TLS加密
// 获取Session实例:
Session session = Session.getInstance(props, new Authenticator() {
    protected PasswordAuthentication getPasswordAuthentication() {
        return new PasswordAuthentication(username, password);
    }
});
// 设置debug模式便于调试:
session.setDebug(true);

以 587 端口为例,连接 SMTP 服务器时,需要准备一个 Properties 对象,填入相关信息。最后获取 Session 实例时,如果服务器需要认证,还需要传入一个 Authenticator 对象,并返回指定的用户名和口令。

当我们获取到 Session 实例后,打开调试模式可以看到 SMTP 通信的详细内容,便于调试。

4.2 发送电子邮件

发送邮件时,需要构造一个 Message 对象,然后调用 Transport.send(Message) 即可完成发送:

MimeMessage message = new MimeMessage(session);
// 设置发送方地址:
message.setFrom(new InternetAddress("me@example.com"));
// 设置接收方地址:
message.setRecipient(Message.RecipientType.TO, new InternetAddress("xiaoming@somewhere.com"));
// 设置邮件主题:
message.setSubject("Hello", "UTF-8");
// 设置邮件正文:
message.setText("Hi Xiaoming...", "UTF-8");
// 发送:
Transport.send(message);

绝大多数邮件服务器要求发送方地址和登录用户名必须一致,否则发送将失败。

填入真实的地址,运行上述代码,可以在控制台看到 JavaMail 打印的调试信息:

这是JavaMail打印的调试信息:
DEBUG: setDebug: JavaMail version 1.6.2
DEBUG: getProvider() returning javax.mail.Provider[TRANSPORT,smtp,com.sun.mail.smtp.SMTPTransport,Oracle]
DEBUG SMTP: need username and password for authentication
DEBUG SMTP: protocolConnect returning false, host=smtp.office365.com, ...
DEBUG SMTP: useEhlo true, useAuth true
开始尝试连接smtp.office365.com:
DEBUG SMTP: trying to connect to host "smtp.office365.com", port 587, ...
DEBUG SMTP: connected to host "smtp.office365.com", port: 587
发送命令EHLO:
EHLO localhost
SMTP服务器响应250:
250-SG3P274CA0024.outlook.office365.com Hello
250-SIZE 157286400
...
DEBUG SMTP: Found extension "SIZE", arg "157286400"
发送命令STARTTLS:
STARTTLS
SMTP服务器响应220:
220 2.0.0 SMTP server ready
EHLO localhost
250-SG3P274CA0024.outlook.office365.com Hello [111.196.164.63]
250-SIZE 157286400
250-PIPELINING
250-...
DEBUG SMTP: Found extension "SIZE", arg "157286400"
...
尝试登录:
DEBUG SMTP: protocolConnect login, host=smtp.office365.com, user=********, password=********
DEBUG SMTP: Attempt to authenticate using mechanisms: LOGIN PLAIN DIGEST-MD5 NTLM XOAUTH2 
DEBUG SMTP: Using mechanism LOGIN
DEBUG SMTP: AUTH LOGIN command trace suppressed
登录成功:
DEBUG SMTP: AUTH LOGIN succeeded
DEBUG SMTP: use8bit false
开发发送邮件,设置FROM:
MAIL FROM:<********@outlook.com>
250 2.1.0 Sender OK
设置TO:
RCPT TO:<********@sina.com>
250 2.1.5 Recipient OK
发送邮件数据:
DATA
服务器响应354:
354 Start mail input; end with <CRLF>.<CRLF>
真正的邮件数据:
Date: Mon, 2 Dec 2019 09:37:52 +0800 (CST)
From: ********@outlook.com
To: ********001@sina.com
Message-ID: <1617791695.0.1575250672483@localhost>
邮件主题是编码后的文本:
Subject: =?UTF-8?Q?JavaMail=E9=82=AE=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: base64

邮件正文是Base64编码的文本:
SGVsbG8sIOi/meaYr+S4gOWwgeadpeiHqmphdmFtYWls55qE6YKu5Lu277yB
.
邮件数据发送完成后,以\r\n.\r\n结束,服务器响应250表示发送成功:
250 2.0.0 OK <HK0PR03MB4961.apcprd03.prod.outlook.com> [Hostname=HK0PR03MB4961.apcprd03.prod.outlook.com]
DEBUG SMTP: message successfully delivered to mail server
发送QUIT命令:
QUIT
服务器响应221结束TCP连接:
221 2.0.0 Service closing transmission channel

从上面的调试信息可以看出,SMTP 协议是一个请求-响应协议,客户端总是发送命令,然后等待服务器响应。服务器响应总是以数字开头,后面的信息才是用于调试的文本。这些响应码已经被定义在
 SMTP 协议中了,查看具体的响应码就可以知道出错原因。

如果一切顺利,对方将收到一封文本格式的电子邮件。

4.3 发送 HTML 邮件

发送 HTML 邮件和文本邮件是类似的,只需要把:

message.setText(body, "UTF-8");

改为:

message.setText(body, "UTF-8", "html");

传入的 body 是类似 <h1>Hello</h1><p>Hi, xxx</p> 这样的 HTML 字符串即可。

HTML 邮件可以在邮件客户端直接显示为网页格式。

4.4 发送附件

要在电子邮件中携带附件,就不能直接调用 message.setText() 方法,而是要构造一个 Multipart 对象:

Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent(body, "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "application/octet-stream")));
multipart.addBodyPart(imagepart);
// 设置邮件内容为multipart:
message.setContent(multipart);

一个 Multipart 对象可以添加若干个 BodyPart,其中第一个 BodyPart 是文本,即邮件正文,后面的 BodyPart 是附件。BodyPart 依靠 setContent() 决定添加的内容,如果添加文本,用 setContent("...", "text/plain;charset=utf-8") 添加纯文本,或者用 setContent("...", "text/html;charset=utf-8") 添加 HTML 文本。如果添加附件,需要设置文件名(不一定和真实文件名一致),并且添加一个 DataHandler(),传入文件的 MIME 类型。二进制文件可以用 application/octet-stream,Word 文档则是 application/msword

最后,通过 setContent() 把 Multipart 添加到 Message 中,即可发送。

带附件的邮件在客户端会被提示下载。

4.5 发送内嵌图片的 HTML 邮件

HTML 邮件中可以内嵌图片,这是怎么做到的?

如果给一个 <img src="http://example.com/test.jpg">,这样的外部图片链接通常会被邮件客户端过滤,并提示用户显示图片并不安全。只有内嵌的图片才能正常在邮件中显示。

内嵌图片实际上也是一个附件,即邮件本身也是 Multipart,但需要做一点额外的处理:

Multipart multipart = new MimeMultipart();
// 添加text:
BodyPart textpart = new MimeBodyPart();
textpart.setContent("<h1>Hello</h1><p><img src=\"cid:img01\"></p>", "text/html;charset=utf-8");
multipart.addBodyPart(textpart);
// 添加image:
BodyPart imagepart = new MimeBodyPart();
imagepart.setFileName(fileName);
imagepart.setDataHandler(new DataHandler(new ByteArrayDataSource(input, "image/jpeg")));
// 与HTML的<img src="cid:img01">关联:
imagepart.setHeader("Content-ID", "<img01>");
multipart.addBodyPart(imagepart);

在 HTML 邮件中引用图片时,需要设定一个 ID,用类似 <img src=\"cid:img01\"> 引用,然后,在添加图片作为 BodyPart 时,除了要正确设置 MIME 类型(根据图片类型使用 image/jpeg 或 image/png),还需要设置一个 Header:

imagepart.setHeader("Content-ID", "<img01>");

这个 ID 和 HTML 中引用的 ID 对应起来,邮件客户端就可以正常显示内嵌图片。

4.6 常见问题

如果用户名或口令错误,会导致 535 登录失败:

DEBUG SMTP: AUTH LOGIN failed
Exception in thread "main" javax.mail.AuthenticationFailedException: 535 5.7.3 Authentication unsuccessful [HK0PR03CA0105.apcprd03.prod.outlook.com]

如果登录用户和发件人不一致,会导致 55 4拒绝发送错误:

DEBUG SMTP: MessagingException while sending, THROW: 
com.sun.mail.smtp.SMTPSendFailedException: 554 5.2.0 STOREDRV.Submission.Exception:SendAsDeniedException.MapiExceptionSendAsDenied;

有些时候,如果邮件主题和正文过于简单,会导致 554 被识别为垃圾邮件的错误:

DEBUG SMTP: MessagingException while sending, THROW: 
com.sun.mail.smtp.SMTPSendFailedException: 554 DT:SPM

五 接收 email

发送 Email 的过程如上已知,客户端总是通过 SMTP 协议把邮件发送给 MTA。

接收 Email 则相反,因为邮件最终到达收件人的 MDA 服务器,所以,接收邮件是收件人用自己的客户端把邮件从 MDA 服务器上抓取到本地的过程。

接收邮件使用最广泛的协议是 POP3(Post Office Protocol version 3):它也是一个建立在 TCP 连接之上的协议。POP3 服务器的标准端口是 110,如果整个会话需要加密,那么使用加密端口 995。

另一种接收邮件的协议是IMAP(Internet Mail Access Protocol):它使用标准端口 143 和加密端口 993。IMAP 和 POP3 的主要区别是,IMAP 协议在本地的所有操作都会自动同步到服务器上,并且,IMAP 可以允许用户在邮件服务器的收件箱中创建文件夹。

JavaMail 也提供了 IMAP 协议的支持。因为 POP3 和 IMAP 的使用方式非常类似,因此只介绍 POP3 的用法。

使用 POP3 收取 Email 时,无需关心 POP3 协议底层,因为 JavaMail 提供了高层接口。首先需要连接到 Store 对象:

// 准备登录信息:
String host = "pop3.example.com";
int port = 995;
String username = "bob@example.com";
String password = "password";

Properties props = new Properties();
props.setProperty("mail.store.protocol", "pop3"); // 协议名称
props.setProperty("mail.pop3.host", host);// POP3主机名
props.setProperty("mail.pop3.port", String.valueOf(port)); // 端口号
// 启动SSL:
props.put("mail.smtp.socketFactory.class", "javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.socketFactory.port", String.valueOf(port));

// 连接到Store:
URLName url = new URLName("pop3", host, post, "", username, password);
Session session = Session.getInstance(props, null);
session.setDebug(true); // 显示调试信息
Store store = new POP3SSLStore(session, url);
store.connect();

一个 Store 对象表示整个邮箱的存储,要收取邮件,需要通过 Store 访问指定的 Folder(文件夹),通常是 INBOX 表示收件箱:

// 获取收件箱:
Folder folder = store.getFolder("INBOX");
// 以读写方式打开:
folder.open(Folder.READ_WRITE);
// 打印邮件总数/新邮件数量/未读数量/已删除数量:
System.out.println("Total messages: " + folder.getMessageCount());
System.out.println("New messages: " + folder.getNewMessageCount());
System.out.println("Unread messages: " + folder.getUnreadMessageCount());
System.out.println("Deleted messages: " + folder.getDeletedMessageCount());
// 获取每一封邮件:
Message[] messages = folder.getMessages();
for (Message message : messages) {
    // 打印每一封邮件:
    printMessage((MimeMessage) message);
}

当我们获取到一个 Message 对象时,可以强制转型为 MimeMessage,然后打印出邮件主题、发件人、收件人等信息:

void printMessage(MimeMessage msg) throws IOException, MessagingException {
    // 邮件主题:
    System.out.println("Subject: " + MimeUtility.decodeText(msg.getSubject()));
    // 发件人:
    Address[] froms = msg.getFrom();
    InternetAddress address = (InternetAddress) froms[0];
    String personal = address.getPersonal();
    String from = personal == null ? address.getAddress() : (MimeUtility.decodeText(personal) + " <" + address.getAddress() + ">");
    System.out.println("From: " + from);
    // 继续打印收件人:
    ...
}

比较麻烦的是获取邮件的正文。一个 MimeMessage 对象也是一个 Part 对象,它可能只包含一个文本,也可能是一个 Multipart 对象,即由几个 Part 构成,因此,需要递归地解析出完整的正文:

String getBody(Part part) throws MessagingException, IOException {
    if (part.isMimeType("text/*")) {
        // Part是文本:
        return part.getContent().toString();
    }
    if (part.isMimeType("multipart/*")) {
        // Part是一个Multipart对象:
        Multipart multipart = (Multipart) part.getContent();
        // 循环解析每个子Part:
        for (int i = 0; i < multipart.getCount(); i++) {
            BodyPart bodyPart = multipart.getBodyPart(i);
            String body = getBody(bodyPart);
            if (!body.isEmpty()) {
                return body;
            }
        }
    }
    return "";
}

最后记得关闭 Folder 和 Store

folder.close(true); // 传入true表示删除操作会同步到服务器上(即删除服务器收件箱的邮件)
store.close();

六 HTTP 编程

HTTP 是目前使用最广泛的 Web 应用程序使用的基础协议,例如,浏览器访问网站,手机 App 访问后台服务器,都是通过 HTTP 协议实现的。

HTTP(HyperText Transfer Protocol, 超文本传输协议) 它是基于 TCP 协议之上的一种请求-响应协议。

我们来看一下浏览器请求访问某个网站时发送的 HTTP 请求-响应。当浏览器希望访问某个网站时,浏览器和网站服务器之间首先建立 TCP 连接,且服务器总是使用 80 端口和加密端口 443,然后,浏览器向服务器发送一个 HTTP 请求,服务器收到后,返回一个 HTTP 响应,并且在响应中包含了 HTML 的网页内容,这样,浏览器解析 HTML 后就可以给用户显示网页了。一个完整的 HTTP 请求-响应如下:

 HTTP 请求的格式是固定的,它由 HTTP Header 和 HTTP Body 两部分构成。第一行总是请求方法 路径 HTTP 版本,例如,GET / HTTP/1.1 表示使用 GET 请求,路径是/,版本是 HTTP/1.1

后续的每一行都是固定的 Header: Value 格式,称为 HTTP Header,服务器依靠某些特定的 Header 来识别客户端请求,例如:

  • Host:表示请求的域名,因为一台服务器上可能有多个网站,因此有必要依靠 Host 来识别请求是发给哪个网站的;
  • User-Agent:表示客户端自身标识信息,不同的浏览器有不同的标识,服务器依靠 User-Agent 判断客户端类型是 IE 还是 Chrome,是 Firefox 还是一个 Python 爬虫;
  • Accept:表示客户端能处理的 HTTP 响应格式,*/* 表示任意格式,text/* 表示任意文本,image/png 表示 PNG 格式的图片;
  • Accept-Language:表示客户端接收的语言,多种语言按优先级排序,服务器依靠该字段给用户返回特定语言的网页版本。

如果是 GET 请求,那么该 HTTP 请求只有 HTTP Header,没有 HTTP Body。如果是 POST 请求,那么该 HTTP 请求带有 Body,以一个空行分隔。一个典型的带 Body 的 HTTP 请求如下:

POST /login HTTP/1.1
Host: www.baidu.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 30

username=hello&password=123456

POST 请求通常要设置 Content-Type 表示 Body 的类型,Content-Length 表示 Body 的长度,这样服务器就可以根据请求的 Header 和 Body 做出正确的响应。

此外,GET 请求的参数必须附加在 URL 上,并以 URLEncode 方式编码,例如:http://www.example.com/?a=1&b=K%26R,参数分别是 a=1 和 b=K&R。因为 URL 的长度限制, GET 请求的参数不能太多,而 POST 请求的参数就没有长度限制,因为 POST 请求的参数必须放到 Body 中。并且,POST 请求的参数不一定是 URL 编码,可以按任意格式编码,只需要在 Content-Type 中正确设置即可。常见的发送 JSON 的 POST 请求如下:

POST /login HTTP/1.1
Content-Type: application/json
Content-Length: 38

{"username":"bob","password":"123456"}

HTTP 响应也是由 Header 和 Body 两部分组成,一个典型的 HTTP 响应如下:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 133251

<!DOCTYPE html>
<html><body>
<h1>Hello</h1>
...

响应的第一行总是 HTTP 版本 响应代码 响应说明,例如,HTTP/1.1 200 OK 表示版本是HTTP/1.1,响应编码是 200,响应说明是 OK。客户端只依赖响应代码判断 HTTP 响应是否成功。HTTP 有固定的响应代码:

  • 1xx:表示一个提示性响应,例如101表示将切换协议,常见于 WebSocket 连接;
  • 2xx:表示一个成功的响应,例如200表示成功,206表示只发送了部分内容;
  • 3xx:表示一个重定向的响应,例如301表示永久重定向,303表示客户端应该按指定路径重新发送请求;
  • 4xx:表示一个因为客户端问题导致的错误响应,例如400表示因为 Content-Type 等各种原因导致的无效请求,404表示指定的路径不存在;
  • 5xx:表示一个因为服务器问题导致的错误响应,例如500表示服务器内部故障,503表示服务器暂时无法响应。

当浏览器收到第一个 HTTP 响应后,它解析 HTML 后,又会发送一系列 HTTP 请求,例如,GET /logo.jpg HTTP/1.1 请求一个图片,服务器响应图片请求后,会直接把二进制内容的图片发送给浏览器:

HTTP/1.1 200 OK
Content-Type: image/jpeg
Content-Length: 18391

????JFIFHH??XExifMM?i&??X?...(二进制的JPEG图片)

因此,服务器总是被动地接收客户端的一个 HTTP 请求,然后响应它。客户端则根据需要发送若干个 HTTP 请求。

对于最早期的 HTTP/1.0 协议,每次发送一个 HTTP 请求,客户端都需要先创建一个新的 TCP 连接,然后,收到服务器响应后,关闭这个 TCP 连接。由于建立 TCP 连接就比较耗时,因此,为了提高效率,HTTP/1.1 协议允许在一个 TCP 连接中反复发送-响应,这样就能大大提高效率:

 因为 HTTP 协议是一个请求-响应协议,客户端在发送了一个 HTTP 请求后,必须等待服务器响应后,才能发送下一个请求,这样一来,如果某个响应太慢,它就会堵住后面的请求。

所以,为了进一步提速,HTTP/2.0 允许客户端在没有收到响应的时候,发送多个 HTTP 请求,服务器返回响应的时候,不一定按顺序返回,只要双方能识别出哪个响应对应哪个请求,就可以做到并行发送和接收:

 可见,HTTP/2.0 进一步提高了效率。

既然 HTTP 涉及到客户端和服务器端,和 TCP 类似,也需要针对客户端编程和针对服务器端编程。

本节我们不讨论服务器端的 HTTP 编程,因为服务器端的 HTTP 编程本质上就是编写 Web 服务器,这是一个非常复杂的体系,也是 JavaEE 开发的核心内容。

本节我们只讨论作为客户端的 HTTP 编程。

因为浏览器也是一种 HTTP 客户端,所以,客户端的 HTTP 编程,它的行为本质上和浏览器是一样的,即发送一个 HTTP 请求,接收服务器响应后,获得响应内容。只不过浏览器进一步把响应内容解析后渲染并展示给了用户,而我们使用 Java 进行 HTTP 客户端编程仅限于获得响应内容。

Java 如何使用 HTTP 客户端编程?

Java 标准库提供了基于 HTTP 的包,但是要注意,早期的 JDK 版本是通过 HttpURLConnection 访问 HTTP,典型代码如下:

URL url = new URL("http://www.example.com/path/to/target?a=1&b=2");
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setUseCaches(false);
conn.setConnectTimeout(5000); // 请求超时5秒
// 设置HTTP头:
conn.setRequestProperty("Accept", "*/*");
conn.setRequestProperty("User-Agent", "Mozilla/5.0 (compatible; MSIE 11; Windows NT 5.1)");
// 连接并发送HTTP请求:
conn.connect();
// 判断HTTP响应是否200:
if (conn.getResponseCode() != 200) {
    throw new RuntimeException("bad response");
}		
// 获取所有响应Header:
Map<String, List<String>> map = conn.getHeaderFields();
for (String key : map.keySet()) {
    System.out.println(key + ": " + map.get(key));
}
// 获取响应内容:
InputStream input = conn.getInputStream();
...

上述代码编写比较繁琐,并且需要手动处理 InputStream,所以用起来很麻烦。

从 Java 11 开始,引入了新的 HttpClient,它使用链式调用的 API,能大大简化 HTTP 的处理。

来看一下如何使用新版的 HttpClient。首先需要创建一个全局 HttpClient 实例,因为 HttpClient 内部使用线程池优化多个 HTTP 连接,可以复用:

static HttpClient httpClient = HttpClient.newBuilder().build();

使用 GET 请求获取文本内容代码如下:

import java.net.URI;
import java.net.http.*;
import java.net.http.HttpClient.Version;
import java.time.Duration;
import java.util.*;

public class Main {
    // 全局HttpClient:
    static HttpClient httpClient = HttpClient.newBuilder().build();

    public static void main(String[] args) throws Exception {
        String url = "https://www.sina.com.cn/";
        HttpRequest request = HttpRequest.newBuilder(new URI(url))
            // 设置Header:
            .header("User-Agent", "Java HttpClient").header("Accept", "*/*")
            // 设置超时:
            .timeout(Duration.ofSeconds(5))
            // 设置版本:
            .version(Version.HTTP_2).build();
        HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
        // HTTP允许重复的Header,因此一个Header可对应多个Value:
        Map<String, List<String>> headers = response.headers().map();
        for (String header : headers.keySet()) {
            System.out.println(header + ": " + headers.get(header).get(0));
        }
        System.out.println(response.body().substring(0, 1024) + "...");
    }
}

如果要获取图片这样的二进制内容,只需要把 HttpResponse.BodyHandlers.ofString() 换成 HttpResponse.BodyHandlers.ofByteArray(),就可以获得一个 HttpResponse<byte[]> 对象。如果响应的内容很大,不希望一次性全部加载到内存,可以使用 HttpResponse.BodyHandlers.ofInputStream() 获取一个 InputStream 流。

要使用 POST 请求,要准备好发送的 Body 数据并正确设置 Content-Type

String url = "http://www.example.com/login";
String body = "username=bob&password=123456";
HttpRequest request = HttpRequest.newBuilder(new URI(url))
    // 设置Header:
    .header("Accept", "*/*")
    .header("Content-Type", "application/x-www-form-urlencoded")
    // 设置超时:
    .timeout(Duration.ofSeconds(5))
    // 设置版本:
    .version(Version.HTTP_2)
    // 使用POST并设置Body:
    .POST(BodyPublishers.ofString(body, StandardCharsets.UTF_8)).build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
String s = response.body();

可见发送 POST 数据也十分简单。

七 RMI 远程调用

Java 的 RMI(Remote Method Invocation) 远程调用是指,一个 JVM 中的代码可以通过网络实现远程调用另一个 JVM 的某个方法。

提供服务的一方称之为服务器,而实现远程调用的一方称之为客户端。

先来实现一个最简单的 RMI:服务器会提供一个 WorldClock 服务,允许客户端获取指定时区的时间,即允许客户端调用下面的方法:

LocalDateTime getLocalDateTime(String zoneId);

要实现 RMI,服务器和客户端必须共享同一个接口。我们定义一个 WorldClock 接口,代码如下:

public interface WorldClock extends Remote {
    LocalDateTime getLocalDateTime(String zoneId) throws RemoteException;
}

Java 的 RMI 规定此接口必须派生自 java.rmi.Remote,并在每个方法声明抛出 RemoteException

下一步是编写服务器的实现类,因为客户端请求的调用方法 getLocalDateTime() 最终会通过这个实现类返回结果。实现类 WorldClockService 代码如下:

public class WorldClockService implements WorldClock {
    @Override
    public LocalDateTime getLocalDateTime(String zoneId) throws RemoteException {
        return LocalDateTime.now(ZoneId.of(zoneId)).withNano(0);
    }
}

现在,服务器端的服务相关代码就编写完毕。我们需要通过 Java RMI 提供的一系列底层支持接口,把上面编写的服务以 RMI 的形式暴露在网络上,客户端才能调用:

public class Server {
    public static void main(String[] args) throws RemoteException {
        System.out.println("create World clock remote service...");
        // 实例化一个WorldClock:
        WorldClock worldClock = new WorldClockService();
        // 将此服务转换为远程服务接口:
        WorldClock skeleton = (WorldClock) UnicastRemoteObject.exportObject(worldClock, 0);
        // 将RMI服务注册到1099端口:
        Registry registry = LocateRegistry.createRegistry(1099);
        // 注册此服务,服务名为"WorldClock":
        registry.rebind("WorldClock", skeleton);
    }
}

上述代码主要目的是通过 RMI 提供的相关类,将我们自己的 WorldClock 实例注册到 RMI 服务上。RMI 的默认端口是 1099,最后一步注册服务时通过 rebind() 指定服务名称为 "WorldClock"

下一步就可以编写客户端代码。RMI 要求服务器和客户端共享同一个接口,因此我们要把WorldClock.java 这个接口文件复制到客户端,然后在客户端实现 RMI 调用:

public class Client {
    public static void main(String[] args) throws RemoteException, NotBoundException {
        // 连接到服务器localhost,端口1099:
        Registry registry = LocateRegistry.getRegistry("localhost", 1099);
        // 查找名称为"WorldClock"的服务并强制转型为WorldClock接口:
        WorldClock worldClock = (WorldClock) registry.lookup("WorldClock");
        // 正常调用接口方法:
        LocalDateTime now = worldClock.getLocalDateTime("Asia/Shanghai");
        // 打印调用结果:
        System.out.println(now);
    }
}

先运行服务器,再运行客户端。从运行结果可知,因为客户端只有接口,并没有实现类,因此,客户端获得的接口方法返回值实际上是通过网络从服务器端获取的。整个过程实际上非常简单,对客户端来说,客户端持有的 WorldClock 接口实际上对应了一个“实现类”,它是由 Registry 内部动态生成的,并负责把方法调用通过网络传递到服务器端。而服务器端接收网络调用的服务并不是我们自己编写的 WorldClockService,而是 Registry 自动生成的代码。我们把客户端的“实现类”称为 stub,而服务器端的网络服务类称为 skeleton,它会真正调用服务器端的 WorldClockService,获取结果,然后把结果通过网络传递给客户端。整个过程由 RMI 底层负责实现序列化和反序列化:

 Java 的 RMI 严重依赖序列化和反序列化,而这种情况下可能会造成严重的安全漏洞,因为 Java 的序列化和反序列化不但涉及到数据,还涉及到二进制的字节码,即使使用白名单机制也很难保证100%排除恶意构造的字节码。因此,使用 RMI 时,双方必须是内网互相信任的机器,不要把 1099 端口暴露在公网上作为对外服务。

此外,Java 的 RMI 调用机制决定了双方必须是 Java 程序,其他语言很难调用 Java 的 RMI。如果要使用不同语言进行 RPC 调用,可以选择更通用的协议,例如 gRPC。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值