Java 网络编程详解(一)

35 篇文章 11 订阅

网络通信基础概念

网络通讯的三要素:

  • IP地址
  • 端口号
  • 传输协议

下面通过一张图来描述下,三者之间的关系和作用:


 

网络模型:计算机网络是指由通信线路互相连接的许多自主工作的计算机构成的集合体,各个部件之间以何种规则进行通信,就是网络模型研究的问题。网络模型一般是指 OSI 七层参考模型和 TCP/IP 四层参考模型。这两个模型在网络中应用最为广泛。

网络模型分为 OSI 模型和 TCP/IP 模型,如下图:


OSI 的原理如下所示:

 

TCP/IP 模型图:

IP 是网络中设备的标识,因为不易于记忆可以使用主机名称。例如:本地主机地址: 127.0.0.1,名称: localhost

 

Java 网络编程 API

Java 网络编程 API 获取IP地址:

  • static InetAddress    
  • getLocalHost() 得到本地主机
  • static InetAddress    getByName(String host) 通过主机名称得到主机
  • String  getHostName() 得到主机的名称
  • String  getHostAddress() 得到主机的地址
  • static InetAddress []    getAllByName(String host) 根据名称得到多个主机

 

1,获取本机的主机:

public static void getLocalHost() throws UnknownHostException {
    InetAddress i = InetAddress.getLocalHost();
    // 打印InetAddress
    System.out.println(i);
    // 打印主机名称
    System.out.println("name:" + i.getHostName());
    // 打印主机地址
    System.out.println("address:" + i.getHostAddress());
}

输出结果:

johnny/192.168.1.102
name:johnny
address:192.168.1.102

2,获取远程的主机,如百度:

public static void getWANHost1() throws UnknownHostException {
    InetAddress i = InetAddress.getByName("www.baidu.com");
    System.out.println(i);
}

控制台输出:www.baidu.com/119.75.217.56

因为百度的主机可能不只一台:

public static void getWANHost() throws UnknownHostException {
    InetAddress[] i = InetAddress.getAllByName("www.baidu.com");
    for (int j = 0; j < i.length; j++) {
        System.out.println(i[j]);
    }
}

控制台输出:

www.baidu.com/119.75.218.45
www.baidu.com/119.75.217.56

端口号:用于标识进程的逻辑地址。有效的端口,0~65535,其中 0~1024 是系统使用或者保留的端口。
 
传输协议:通讯的规则,常用的有 TCP 和 UDP 协议

下面来介绍一下这两个协议的特点和区别。

UDP 协议:

  1. 将数据及源和目的的封装成数据包中,不需要建立连接
  2. 每一个数据包的大小限制在64K内;
  3. 因为无连接,是不可靠协议;
  4. 不需要建立连接,速度快

 
TCP 协议:

  1. 建立连接,形成传输数据的通信
  2. 在连接中进行大数据的传输;
  3. 通过三次握手完成连接,是可靠连接;比如你和张三连接,第一次问张三是否在,第二次张三收到告诉你在,第三次告诉张三你知道了。
  4. 必须建立连接,效率会略低.

 
Socket:

  1. Socket 是为网络服务提供的一种机制;
  2. 通信的两端都是 Socket;
  3. 网络通信就是 Socket 间的通信;
  4. 数据在两个 Socket 间通过 IO 传输;

 
为了帮助理解 Socket,我在网上搜索了一段文件来解说:

Socket 非常类似于电话插座。以一个国家级电话网为例。电话的通话双方相当于相互通信的 2 个进程,区号是它的网络地址;
区内一个单位的交换机相当于一台主机,主机分配给每个用户的局内号码相当于Socket号。
任何用户在通话之前,首先要占有一部电话机,相当于申请一个 Socket,同时要知道对方的号码,相当于对方有一个固定的 Socket。
然后向对方拨号呼叫,相当于发出连接请求(假如对方不在同一区内,还要拨对方区号,相当于给出网络地址)。
对方假如在场并空闲(相当于通信的另一主机开机且可以接受连接请求),拿起电话话筒,双方就可以正式通话,相当于连接成功。
双方通话的过程,是一方向电话机发出信号和对方从电话机接收信号的过程,相当于向 Socket 发送数据和从 Socket 接收数据。通话结束后,一方挂起电话机相当于关闭 Socket,撤消连接。

 

Java 网络编程 UDP 操作

Java 用于 UDP 操作 Socket 常用的有:

java.lang.Object
      java.net.DatagramSocket

这个类描述了为发送和接受数据报包的 Socket,常用的构造方法有:

  • DatagramSocket(int port) 
  • DatagramSocket(int port, InetAddress laddr) 

既然该类提供了发送和接受的功能,那么它就提供了这样的方法:

  • receive(DatagramPacket p) 接受数据,把接受到的数据封装大DatagramPacket 中,这个方法是一个阻塞方法,也就是如果没有接受大数据,就会一直等待
  • send(DatagramPacket p) 发送数据包


java.lang.Object
    java.net.DatagramPacket

该类用于非连接状态下,邮寄数据服务,常用的构造方法有:

  • DatagramPacket(byte[] buf, int length, InetAddress address, int port) 
  • DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port) 

所以 UDP 操作涉及到两个类:

  • DatagramSocket
  • DatagramPacket


现在我们来完成一小程序,使用 UDP 协议,来发送一个数据包:

public class UdpSend {
 
   public static void main(String[] args) throws Exception {
        // 1 ,创建UDP服务,创建DatagramSocket对象
        DatagramSocket Socket = new DatagramSocket();
        // 2,确定数据并封装成数据报包
        byte[] buffer = "I'm yuzhiqiang".getBytes();
        //往端口 13200,主机为"192.168.1.102"发送buffer字节数组里面的数据
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length,
                InetAddress.getByName("192.168.1.102"), 13200);
        // 通过服务发送数据
        Socket.send(packet);
        // 关闭资源
        Socket.close();
    }
}


下面我们来定义一个接受数据包的程序:

public class UdpReceive {

    public static void main(String[] args) throws Exception {
        DatagramSocket Socket = new DatagramSocket();
        byte[] buffer = new byte[1024];
        DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
        Socket.receive(packet);
        //通过数据包获取其中的数据
        String ip = packet.getAddress().getHostAddress();
        String data = new String(packet.getData(),0,packet.getLength());
        //获取是从哪个端口发过来的 
        int port = packet.getPort();
        System.out.println(ip+"--"+data+"--"+port);
        Socket.close();
    }
}


我们打开 cmd 命令,现在我们不能先运行发送数据包的程序,因为如果接收数据包的程序没有启动发送的数据就会丢失。先运行接受程序, 然后运行发送程序,如图所示:


 

两个程序运行完毕后,任然没有看到我们要发送的数据,为什么呢?因为我们是向端口13200 发送数据,而接受的数据的程序不一定是监听 13200 端口的程序。所以我们必须把接收程序的端口设置成 13200,而不是由系统自动分配端口。
所以在 UdpReceice.java 中构建 DatagramSocket 需要把对应的端口 13200 作为参数传递进去,如:new DatagramSocket(13200)。

重新编译后, 再次按照上面的顺序运行这两个程序,程序阻塞等待发送:


 

发现阻塞的程序发生了变化,输出的数据正式我们发送的!并且还有对方法的端口和 IP

这个对方的端口也是系统生成的,那么我们能不能指定他呢?只要把发送端的程序 UdpSend.java 中构建 DatagramSocket  对象的时候将端口作为传参传递进去即可。如DatagramSocket Socket = new DatagramSocket(9999);

再次运行发现 63169 变为了 9999,如下图所示:


我们知道我们发过去的数据是在程序硬编码的,我们能不能在键盘上输入文字在发过去呢?而在接收端不停的接收呢?下面我们来实现下:

// 发送端代码如下:

    public static void main(String[] args) throws IOException {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        DatagramSocket Socket = new DatagramSocket();
        String line = null;
        while ((line = br.readLine()) != null) {
            if ("886".equals(line)) {
                break;
            }
            byte[] buffer = line.getBytes();
            // 如果把ip主机设置成192.168.1.255,那就相当于发送广播,在这个网段内活动的主机都可以收到数据
            DatagramPacket packet = new DatagramPacket(line.getBytes(),
                    buffer.length, InetAddress.getByName("192.168.1.102"),
                    11111);
            Socket.send(packet);
        }
 
        Socket.close();
    }


// 接收端代码:

    public static void main(String[] args) throws Exception {
        DatagramSocket Socket = new DatagramSocket(11111);
        while(true){
            byte[] buffer = new byte[1024];
            DatagramPacket p = new DatagramPacket(buffer,buffer.length);
            Socket.receive(p);
            
            String ip = p.getAddress().getHostAddress();
            String data = new String(p.getData(),0,p.getLength());
            int port = p.getPort();
            System.out.println(ip);
            System.out.println("\t\t"+data+"\t"+port);
        }
    }

程序执行结果如下图所示:

 

我们再来想一下:在接收端,我们能不能把 DatagramSocket Socket =new DatagramSocket(11111); 放进 while 循环里面?

修改完代码,我们来测试一下,发送数据:


 

 

为什么?当第一次循环的时候,创建了端口为 11111 的服务程序,因为在接收端,是死循环,所以当我发送数据过去后,输出信息,它重新执行循环里面的代码,又创建了一个口为 11111 的服务程序,所以出现端口正在被使用的错误。
 

从上可以看出,我们启动了两个 cmd 命令窗口来模拟了上面的发送和接收程序。

我们再来想一想,能不能在一个窗口发信息,也能在同一个窗口收到信息呢?

上面的程序我们有两个进程,现在一个进程里面实现,那么就要用到多线程!

我们首先创建两个线程,一个线程用于发数据,一个线程用于接收数据,两个线程互不影响。例如下面的程序:
 

// 发送

class Send implements Runnable {
    private DatagramSocket Socket;
 
    public Send(DatagramSocket Socket) {
        this.Socket = Socket;
    }
 
    public void run() {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String line = null;
        try {
            while ((line = br.readLine()) != null) {
                if ("886".equals(line))
                    break;
                byte[] buff = line.getBytes();
                //注意192.168.1.255的意义
                DatagramPacket packet = new DatagramPacket(buff, buff.length,
                        InetAddress.getByName("192.168.1.255"), 11111);
                Socket.send(packet);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally{
            Socket.close();
        }
    }
 
}

// 接收

class Receive implements Runnable {
    private DatagramSocket Socket;
 
    public Receive(DatagramSocket Socket) {
        this.Socket = Socket;
    }
    public void run() {
        while (true) {
            byte[] buffer = new byte[1024];
            DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
 
            try {
                Socket.receive(packet);
                String ip = packet.getAddress().getHostAddress();
                String data = new String(packet.getData(), 0, packet
                        .getLength());
                System.out.println(ip);
                System.out.println("\t" + data);
 
            } catch (IOException e) {
                e.printStackTrace();
            } 
 
        }
    }
 
}


// 测试程序:

public class ChatTest {
 
    public static void main(String[] args) throws SocketException {
        DatagramSocket receive = new DatagramSocket(11111);
        DatagramSocket send = new DatagramSocket();
        new Thread(new Send(send)).start();
        new Thread(new Receive(receive)).start();
    }
}

程序运行结果如下图所示:

 

Java 网络编程 TCP 操作

上面是基于 UDP 进行数据传输的,下面来看一下基于 TCP 协议的数据传输。

  • TCP 分为客户端和服务器端
  • 客户端对应的对象是 Socket, 服务器端对应的对象是 ServerSocket.

 一个客户端对象包含输入输出流,通过输出流往服务器发送数据,通过输入流读取数据,我们知道一个服务器可以与多个客户端连接,那么服务端本应该发送数据给 A,那么它不会发送到 B 这个客户端吗?它靠什么来区分的呢?因为一个客户端和服务器连接,服务器就可以得到 Socket 对象,就可以得到 Socket 的输入输出流,它要和特定的客户端互动,就会使用该 Socket 的输入输出流,这样就不会错了,看下面一个原理图:


现在我们来模拟一下这样的程序:

// 服务器端
public class TcpDemo {
 
    public static void main(String[] args) throws IOException {
        //建立一个服务端的Socket服务,并监听(绑定)10001端口
        ServerSocket server = new ServerSocket(10001);
        //获取连接到的客户端对象,此方法是阻塞式方法,因为没有客户端连接 ,就一直等待
        Socket client = server.accept();
        String ip = client.getInetAddress().getHostAddress();
        //打印连接服务器的客户端IP
        System.out.println(ip+"connected...");
        InputStream is = client.getInputStream();
        byte[] buff = new byte[1024];
        int len = is.read(buff);
        System.out.println(new String(buff,0,len));
        client.close();
        //只服务一次
        server.close();
    }
}


//客户端
class Client {
    public static void main(String[] args) throws Exception {
        // 创建客户端的Socket服务,并指定主机和端口
        Socket client = new Socket("192.168.1.102", 10001);
        // 得到输出流,往服务端发送数据
        OutputStream os = client.getOutputStream();
        os.write("你好".getBytes());
        client.close();
    }
}


需要注意的是,我们首先要运行服务端的程序,因为客户端一开始就要和服务器端连接,如果服务器端都没有,那么就不存在连接的问题的,就像我们连接百度,如果百度的服务器都没有开启,我们就没有办法连接了。

运行服务端和客户端代码,服务端程序会进入阻塞状态:

启动客户端程序后,服务器接收到客户端的连接:


 

上面的程序,服务端收到信息,并没有给客户端反馈,现在的需求是服务器端收到数据后,要向客户端发送数据,现在该怎么做呢?因为 Socket 有输入输出流,所以服务端可以通过 Socket 的输入流来实现:

// 服务器端
public class TcpDemo2 {
 
    public static void main(String[] args) throws IOException {
        ServerSocket server = new ServerSocket(22223);
        Socket client = server.accept();
        String ip = client.getInetAddress().getHostAddress();
        System.out.println(ip+".....connected!");
        InputStream is = client.getInputStream();
        byte[] buff = new byte[1024];
        int len = is.read(buff);
        String message = new String(buff, 0, len);
        System.out.println(message);
        
        OutputStream os = client.getOutputStream();
        os.write(("我已经收到你信息:"+"\""+message+"\"").getBytes());
        client.close();
        server.close();
    }
}

// 客户端
class Client2 {
    public static void main(String[] args) throws Exception {
        Socket client = new Socket("192.168.1.102", 22223);
        OutputStream os = client.getOutputStream();
        // 向服务器发数据
        os.write("你好啊!".getBytes());
        InputStream is = client.getInputStream();
        byte[] buff = new byte[1024];
        // 读取服务器发来的数据/read方法是阻塞式方法,没有读到数据就会等
        int len = is.read(buff);
        System.out.println(new String(buff, 0, len));
        client.close();
    }
}

程序运行结果如下图所示:

 

下面我们来看一下实际应用中常见的问题,我们以一个例子展开:
 
需求:建立一个文件转换服务器,客户端给服务器发送文本,服务器将文本转成大写再返回给客户端。而且客户端可以不断的向服务器发送数据,当客户端输入 over 时,转换结束。

分析:这需要我们使用到键盘录入,字符缓冲区等 IO 操作。程序代码如下:

public class TcpTask {
 
    public static void main(String[] args) throws IOException {
        // 监听或绑定23456端口
        ServerSocket server = new ServerSocket(23456);
        // 获取客户端对象
        Socket client = server.accept();
 
        BufferedReader buffIn = new BufferedReader(new InputStreamReader(client
                .getInputStream()));
        BufferedWriter buffOut = new BufferedWriter(new OutputStreamWriter(
                client.getOutputStream()));
        String line = null;
        while ((line = buffIn.readLine()) != null) {
            buffOut.write(line);
            System.out.println(line);
        }
        client.close();
        server.close();
    }
}

class Client3 {
    public static void main(String[] args) throws Exception {
        // 获取服务器对象
        Socket client = new Socket("192.168.1.102", 23456);
        // 首先要获取键盘录入, 转换流就不再介绍了
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        String line = null;
        BufferedWriter buffOut = new BufferedWriter(new OutputStreamWriter(
                client.getOutputStream()));
 
        BufferedReader buffIn = new BufferedReader(new InputStreamReader(client
                .getInputStream()));
        while ((line = br.readLine()) != null) {
            if ("over".equals(line))
                break;
            buffOut.write(line);
            String serverLine = buffIn.readLine();
            System.out.println(serverLine.toUpperCase());
        }
        br.close();
        client.close();
    }
}

 

按照步骤运行上面的程序。运行服务器端程序,服务器端正在阻塞:


客户端正在等待输入:

输入数据后:

发现服务端仍然没有什么变化,并且客户端也无法再次输入数据。为什么会出现这么奇怪的现象呢?

首先我们来看两段代码:

// 客户端代码片段

while ((line = br.readLine()) != null) {
    if ("over".equals(line))
        break;
    buffOut.write(line);
    String serverLine = buffIn.readLine();
    System.out.println(serverLine.toUpperCase());
}
        
// 服务端代码片段:

while ((line = buffIn.readLine()) != null) {
    buffOut.write(line);
    System.out.println(line);
}

1、看到上面的代码我们不难发现,我们在往服务器端发数据和服务器端往客户端发数据的时候使用的是字符缓冲区的流,但是我们并没有刷新,此时数据任然在内存中。所以应该调用 flush() 方法。重新运行,但是还是没有把问题解决,程序运行情况如下:


2、我们看第二个代码片段的 while 循环,line = buffIn.readLine() 说明服务器端正在读取客户端发来的数据,我们在前面学过,readLine() 方法是阻塞式方法,并且只有遇到换行符的时候就不再阻塞,然后客户端发来的数据,并不包含换行符,所以这个方法一直在阻塞,所以我们在客户端代码出添加一行代码:buffOut.newLine();  如:

while ((line = br.readLine()) != null) {
    if ("over".equals(line))
        break;
    buffOut.write(line);
    buffOut.newLine();
    buffOut.flush();
    String serverLine = buffIn.readLine();
    System.out.println(serverLine.toUpperCase());
}

同理,在服务端处,也要这样做,如下所示:

while ((line = buffIn.readLine()) != null) {
    buffOut.write(line);
    buffOut.newLine();
    buffOut.flush();
    System.out.println(line);
}

重新编译,再运行结果,服务端如下图所示:

客户端运行如下所示:

 

小结:我们在使用缓冲区的流的时候,诸如 BufferedXX,我们要注意 flush() 刷新问题!

其次我们要明白 readLine() 与 read() 方法的区别:

  • readLine() 是缓冲区流对象的方法,而 read() 不是;
  • read() 当我们使用字节输出流读取键盘录入的数据时,他会把换行符也会读取到,而 readLine() 方法不会读取换行符
  • read 和 readLine 都属于阻塞式方法。就拿上面的例子来说,因为服务器可能在使用客户端是输出流写数据,所以在客户端读取数据的时候,就会出现阻塞,因为他不知道什么时候服务器端不在写数据了。因为 readLine 在读取一行的时候并没有读到换行符,所以他认为还在写数据。

查看 readLine() 的 API:


 

意思是说该方法,读取文本的一行数据,一行的结尾被认为是 \r\n,当然其他的系统就不同了,它返回的是行的内容,不包含行终止符,当到达流的尾端就返回 null,我们可以编写一个小程序验证一下:

public static void main(String[] args) throws Exception {
    InputStream is = System.in;
    OutputStream os = new FileOutputStream("test3.txt");
    byte[] buff = new byte[10];
    int len = is.read(buff);
    System.out.println(len);
    os.write(buff, 0, len); 
    os.close();
} 

程序运行结果:

然后,我们来测试一下 readLine():

public static void main(String[] args) throws Exception {
    // 字符缓冲流
    BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
    OutputStream os = new FileOutputStream("test3.txt");
    String line = br.readLine();
    System.out.println(line.length());
    os.write(line.getBytes());
}

程序运行结果:

 

最后,我们通过一个程序使用 TCP 复制一个文件的功能,来引出 结束标记 的概念:

public class TcpCopyFile {
 
    public static void main(String[] args) throws Exception {
 
        ServerSocket ss = new ServerSocket(65432);
 
        Socket Socket = ss.accept();
        String ip = Socket.getInetAddress().getHostAddress();
        System.out.println(ip+".....connected!");
        // 用该对象读取客户端发来的信息
        BufferedReader br = new BufferedReader(new InputStreamReader(Socket
                .getInputStream()));
        // 把读到的内容保存到指定 的文件中
        PrintWriter pw = new PrintWriter(new FileWriter("testCopy.txt"), true);
        String line = null;
        while ((line = br.readLine()) != null) {
            pw.println(line);
        }
        // 把信息反馈给客户端
        PrintWriter out = new PrintWriter(Socket.getOutputStream(), true);
        out.println("上传完毕");
        out.close();
        pw.close();
        Socket.close();
        ss.close();
    }
}
 
class Client4 {
 
    public static void main(String[] args) throws Exception {
        // 创建客户端服务
        Socket Socket = new Socket("192.168.1.102", 65432);
        // 读取test.txt文件
        BufferedReader br = new BufferedReader(new FileReader("test.txt"));
        // 把文件的内容发送到客户端
        PrintWriter pw = new PrintWriter(Socket.getOutputStream(), true);
        String line = null;
        while ((line = br.readLine()) != null) {
            pw.println(line);
        }
        // 读取服务器发送的反馈信息
        BufferedReader buffIn = new BufferedReader(new InputStreamReader(Socket
                .getInputStream()));
        String message = buffIn.readLine();
        System.out.println(message);
        //关闭资源
        buffIn.close();
        pw.close();
        br.close();
        Socket.close();
    }
}

程序运行结果如下图所示:

通过查看 testCopy.txt 文件内容,内容的传输是对的,但是服务器端程序并没有输出:"上传完毕"。

在解释这个问题之前,我们先来看下两个代码片段:

// 客户端:

while ((line = br.readLine()) != null) {
    pw.println(line);
}
// 读取服务器发送的反馈信息
BufferedReader buffIn = new BufferedReader(new InputStreamReader(Socket
        .getInputStream()));
String message = buffIn.readLine();

// 服务端:

while ((line = br.readLine()) != null) {
    pw.println(line);
}
// 把信息反馈给客户端
PrintWriter out = new PrintWriter(Socket.getOutputStream(), true);
out.println("上传完毕");

当客户端把数据全部发送到了,执行到代码 String message = buffIn.readLine(); 处,程序处于阻塞状态。

在服务器端,把客户端发来的数据全部写入了 testCopy.txt 文件,但是他并不知道客户端是否还会通过 OutputStream 发数据,所以客户端 line = br.readLine() 一直处于阻塞状态等待读取数据。所以服务器反馈的信息代码没没机会执行,可以通过 Socket.shutdownOutput() 告诉服务器端我不在传输数据了,如:

while ((line = br.readLine()) != null) {
    pw.println(line);
}
//当发完数据后,告诉服务器端,我不在传输数据了
Socket.shutdownOutput();

程序运行正常,当然我们也可以通过自定义标记,如在客户端发送一条数据(比如 "over"),告诉服务器数据已经传输完毕,然后服务器端就 break。但是如果文本文件中存在 over 字符那么,数据还没有读完就会 break,导致数据不完整。

 

Java 网络编程总结

至此,关于 Java 中的网络的编程就介绍完了,主要涉及如下几个类:

  • DatagramPacket
  • DatagramSocket
  • Socket
  • SocketServer

虽然网络相关的 API 不是非常多,但是网络编程和 I/O 操作非常紧密,所以需要大家熟练掌握 Java 中的 I/O 操作。

如果对 Java I/O 相关的知识不是很熟悉,可以参考我之前写的文章:

希望本文对你有帮助。

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Chiclaim

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值