• Java从萌新小白到顶级大牛(7更新中)


    OutputStream

    InputStream相反,OutputStream是Java标准库提供的最基本的输出流。

    InputStream类似,OutputStream也是抽象类,它是所有输出流的超类。这个抽象类定义的一个最重要的方法就是void write(int b),签名如下:

    public abstract void write(int b) throws IOException;

    这个方法会写入一个字节到输出流。要注意的是,虽然传入的是int参数,但只会写入一个字节,即只写入int最低8位表示字节的部分(相当于b & 0xff)。

    InputStream类似,OutputStream也提供了close()方法关闭输出流,以便释放系统资源。要特别注意:OutputStream还提供了一个flush()方法,它的目的是将缓冲区的内容真正输出到目的地。

    为什么要有flush()?因为向磁盘、网络写入数据的时候,出于效率的考虑,操作系统并不是输出一个字节就立刻写入到文件或者发送到网络,而是把输出的字节先放到内存的一个缓冲区里(本质上就是一个byte[]数组),等到缓冲区写满了,再一次性写入文件或者网络。对于很多IO设备来说,一次写一个字节和一次写1000个字节,花费的时间几乎是完全一样的,所以OutputStream有个flush()方法,能强制把缓冲区内容输出。

    通常情况下,我们不需要调用这个flush()方法,因为缓冲区写满了OutputStream会自动调用它,并且,在调用close()方法关闭OutputStream之前,也会自动调用flush()方法。

    但是,在某些情况下,我们必须手动调用flush()方法。举个栗子:

    小明正在开发一款在线聊天软件,当用户输入一句话后,就通过OutputStreamwrite()方法写入网络流。小明测试的时候发现,发送方输入后,接收方根本收不到任何信息,怎么肥四?

    原因就在于写入网络流是先写入内存缓冲区,等缓冲区满了才会一次性发送到网络。如果缓冲区大小是4K,则发送方要敲几千个字符后,操作系统才会把缓冲区的内容发送出去,这个时候,接收方会一次性收到大量消息。

    解决办法就是每输入一句话后,立刻调用flush(),不管当前缓冲区是否已满,强迫操作系统把缓冲区的内容立刻发送出去。

    实际上,InputStream也有缓冲区。例如,从FileInputStream读取一个字节时,操作系统往往会一次性读取若干字节到缓冲区,并维护一个指针指向未读的缓冲区。然后,每次我们调用int read()读取下一个字节时,可以直接返回缓冲区的下一个字节,避免每次读一个字节都导致IO操作。当缓冲区全部读完后继续调用read(),则会触发操作系统的下一次读取并再次填满缓冲区。

    FileOutputStream

    我们以FileOutputStream为例,演示如何将若干个字节写入文件流:

    public void writeFile() throws IOException {

        OutputStream output = new FileOutputStream("out/readme.txt");

        output.write(72); // H

        output.write(101); // e

        output.write(108); // l

        output.write(108); // l

        output.write(111); // o

        output.close();

    }

    每次写入一个字节非常麻烦,更常见的方法是一次性写入若干字节。这时,可以用OutputStream提供的重载方法void write(byte[])来实现:

    public void writeFile() throws IOException {

        OutputStream output = new FileOutputStream("out/readme.txt");

        output.write("Hello".getBytes("UTF-8")); // Hello

        output.close();

    }

    InputStream一样,上述代码没有考虑到在发生异常的情况下如何正确地关闭资源。写入过程也会经常发生IO错误,例如,磁盘已满,无权限写入等等。我们需要用try(resource)来保证OutputStream在无论是否发生IO错误的时候都能够正确地关闭:

    public void writeFile() throws IOException {

        try (OutputStream output = new FileOutputStream("out/readme.txt")) {

            output.write("Hello".getBytes("UTF-8")); // Hello

        } // 编译器在此自动为我们写入finally并调用close()

    }

    阻塞

    InputStream一样,OutputStreamwrite()方法也是阻塞的。

    OutputStream实现类

    FileOutputStream可以从文件获取输出流,这是OutputStream常用的一个实现类。此外,ByteArrayOutputStream可以在内存中模拟一个OutputStream

    import java.io.*;

    public class Main {

        public static void main(String[] args) throws IOException {

            byte[] data;

            try (ByteArrayOutputStream output = new ByteArrayOutputStream()) {

                output.write("Hello ".getBytes("UTF-8"));

                output.write("world!".getBytes("UTF-8"));

                data = output.toByteArray();

            }

            System.out.println(new String(data, "UTF-8"));

        }

    }

    ByteArrayOutputStream实际上是把一个byte[]数组在内存中变成一个OutputStream,虽然实际应用不多,但测试的时候,可以用它来构造一个OutputStream

    同时操作多个AutoCloseable资源时,在try(resource) { ... }语句中可以同时写出多个资源,用;隔开。例如,同时读写两个文件:

    // 读取input.txt,写入output.txt:try (InputStream input = new FileInputStream("input.txt");

         OutputStream output = new FileOutputStream("output.txt"))

    {

        input.transferTo(output); // transferTo的作用是?

    }

    练习

    请利用InputStreamOutputStream,编写一个复制文件的程序,它可以带参数运行:

    java CopyFile.java source.txt copy.txt

    小结

    Java标准库的java.io.OutputStream定义了所有输出流的超类:

    FileOutputStream实现了文件流输出;

    ByteArrayOutputStream在内存中模拟一个字节流输出。

    某些情况下需要手动调用OutputStreamflush()方法来强制输出缓冲区。

    总是使用try(resource)来保证OutputStream正确关闭。

    Filter模式

    Java的IO标准库提供的InputStream根据来源可以包括:

    • FileInputStream:从文件读取数据,是最终数据源;
    • ServletInputStream:从HTTP请求读取数据,是最终数据源;
    • Socket.getInputStream():从TCP连接读取数据,是最终数据源;
    • ...

    如果我们要给FileInputStream添加缓冲功能,则可以从FileInputStream派生一个类:

    BufferedFileInputStream extends FileInputStream

    如果要给FileInputStream添加计算签名的功能,类似的,也可以从FileInputStream派生一个类:

    DigestFileInputStream extends FileInputStream

    如果要给FileInputStream添加加密/解密功能,还是可以从FileInputStream派生一个类:

    CipherFileInputStream extends FileInputStream

    如果要给FileInputStream添加缓冲和签名的功能,那么我们还需要派生BufferedDigestFileInputStream。如果要给FileInputStream添加缓冲和加解密的功能,则需要派生BufferedCipherFileInputStream

    我们发现,给FileInputStream添加3种功能,至少需要3个子类。这3种功能的组合,又需要更多的子类:

    1.                           ┌─────────────────┐
    2.                           │ FileInputStream │
    3.                           └─────────────────┘
    4.                                    ▲
    5.              ┌───────────┬─────────┼─────────┬───────────┐
    6.              │           │         │         │           │
    7. ┌───────────────────────┐│┌─────────────────┐│┌─────────────────────┐
    8. │BufferedFileInputStream│││DigestInputStream│││CipherFileInputStream│
    9. └───────────────────────┘│└─────────────────┘│└─────────────────────┘
    10.                          │                   │
    11.     ┌─────────────────────────────┐ ┌─────────────────────────────┐
    12.     │BufferedDigestFileInputStream│ │BufferedCipherFileInputStream│
    13.     └─────────────────────────────┘ └─────────────────────────────┘

    这还只是针对FileInputStream设计,如果针对另一种InputStream设计,很快会出现子类爆炸的情况。

    因此,直接使用继承,为各种InputStream附加更多的功能,根本无法控制代码的复杂度,很快就会失控。

    为了解决依赖继承会导致子类数量失控的问题,JDK首先将InputStream分为两大类:

    一类是直接提供数据的基础InputStream,例如:

    • FileInputStream
    • ByteArrayInputStream
    • ServletInputStream
    • ...

    一类是提供额外附加功能的InputStream,例如:

    • BufferedInputStream
    • DigestInputStream
    • CipherInputStream
    • ...

    当我们需要给一个“基础”InputStream附加各种功能时,我们先确定这个能提供数据源的InputStream,因为我们需要的数据总得来自某个地方,例如,FileInputStream,数据来源自文件:

    InputStream file = new FileInputStream("test.gz");

    紧接着,我们希望FileInputStream能提供缓冲的功能来提高读取的效率,因此我们用BufferedInputStream包装这个InputStream,得到的包装类型是BufferedInputStream,但它仍然被视为一个InputStream

    InputStream buffered = new BufferedInputStream(file);

    最后,假设该文件已经用gzip压缩了,我们希望直接读取解压缩的内容,就可以再包装一个GZIPInputStream

    InputStream gzip = new GZIPInputStream(buffered);

    无论我们包装多少次,得到的对象始终是InputStream,我们直接用InputStream来引用它,就可以正常读取:

    1. ┌─────────────────────────┐
    2. │GZIPInputStream          │
    3. │┌───────────────────────┐│
    4. ││BufferedFileInputStream││
    5. ││┌─────────────────────┐││
    6. │││   FileInputStream   │││
    7. ││└─────────────────────┘││
    8. │└───────────────────────┘│
    9. └─────────────────────────┘

    上述这种通过一个“基础”组件再叠加各种“附加”功能组件的模式,称之为Filter模式(或者装饰器模式:Decorator)。它可以让我们通过少量的类来实现各种功能的组合:

    1.                  ┌─────────────┐
    2.                  │ InputStream │
    3.                  └─────────────┘
    4.                        ▲ ▲
    5. ┌────────────────────┐ │ │ ┌─────────────────┐
    6. │  FileInputStream   │─┤ └─│FilterInputStream│
    7. └────────────────────┘ │   └─────────────────┘
    8. ┌────────────────────┐ │     ▲ ┌───────────────────┐
    9. │ByteArrayInputStream│─┤     ├─│BufferedInputStream│
    10. └────────────────────┘ │     │ └───────────────────┘
    11. ┌────────────────────┐ │     │ ┌───────────────────┐
    12. │ ServletInputStream │─┘     ├─│  DataInputStream  │
    13. └────────────────────┘       │ └───────────────────┘
    14.                              │ ┌───────────────────┐
    15.                              └─│CheckedInputStream │
    16.                                └───────────────────┘

    类似的,OutputStream也是以这种模式来提供各种功能:

    1.                   ┌─────────────┐
    2.                   │OutputStream │
    3.                   └─────────────┘
    4.                         ▲ ▲
    5. ┌─────────────────────┐ │ │ ┌──────────────────┐
    6. │  FileOutputStream   │─┤ └─│FilterOutputStream│
    7. └─────────────────────┘ │   └──────────────────┘
    8. ┌─────────────────────┐ │     ▲ ┌────────────────────┐
    9. │ByteArrayOutputStream│─┤     ├─│BufferedOutputStream│
    10. └─────────────────────┘ │     │ └────────────────────┘
    11. ┌─────────────────────┐ │     │ ┌────────────────────┐
    12. │ ServletOutputStream │─┘     ├─│  DataOutputStream  │
    13. └─────────────────────┘       │ └────────────────────┘
    14.                               │ ┌────────────────────┐
    15.                               └─│CheckedOutputStream │
    16.                                 └────────────────────┘

    编写FilterInputStream

    我们也可以自己编写FilterInputStream,以便可以把自己的FilterInputStream“叠加”到任何一个InputStream中。

    下面的例子演示了如何编写一个CountInputStream,它的作用是对输入的字节进行计数:

    import java.io.*;

    public class Main {

        public static void main(String[] args) throws IOException {

            byte[] data = "hello, world!".getBytes("UTF-8");

            try (CountInputStream input = new CountInputStream(new ByteArrayInputStream(data))) {

                int n;

                while ((n = input.read()) != -1) {

                    System.out.println((char)n);

                }

                System.out.println("Total read " + input.getBytesRead() + " bytes");

            }

        }

    }

    class CountInputStream extends FilterInputStream {

        private int count = 0;

        CountInputStream(InputStream in) {

            super(in);

        }

        public int getBytesRead() {

            return this.count;

        }

        public int read() throws IOException {

            int n = in.read();

            if (n != -1) {

                this.count ++;

            }

            return n;

        }

        public int read(byte[] b, int off, int len) throws IOException {

            int n = in.read(b, off, len);

            if (n != -1) {

                this.count += n;

            }

            return n;

        }

    }

    注意到在叠加多个FilterInputStream,我们只需要持有最外层的InputStream,并且,当最外层的InputStream关闭时(在try(resource)块的结束处自动关闭),内层的InputStreamclose()方法也会被自动调用,并最终调用到最核心的“基础”InputStream,因此不存在资源泄露。

    小结

    Java的IO标准库使用Filter模式为InputStreamOutputStream增加功能:

    可以把一个InputStream和任意个FilterInputStream组合;

    可以把一个OutputStream和任意个FilterOutputStream组合。

    Filter模式可以在运行期动态增加功能(又称Decorator模式)。

    操作Zip

    ZipInputStream是一种FilterInputStream,它可以直接读取zip包的内容:

    1. ┌───────────────────┐
    2. │    InputStream    │
    3. └───────────────────┘
    4.           ▲
    5.           │
    6. ┌───────────────────┐
    7. │ FilterInputStream │
    8. └───────────────────┘
    9.           ▲
    10.           │
    11. ┌───────────────────┐
    12. │InflaterInputStream│
    13. └───────────────────┘
    14.           ▲
    15.           │
    16. ┌───────────────────┐
    17. │  ZipInputStream   │
    18. └───────────────────┘
    19.           ▲
    20.           │
    21. ┌───────────────────┐
    22. │  JarInputStream   │
    23. └───────────────────┘

    另一个JarInputStream是从ZipInputStream派生,它增加的主要功能是直接读取jar文件里面的MANIFEST.MF文件。因为本质上jar包就是zip包,只是额外附加了一些固定的描述文件。

    读取zip包

    我们来看看ZipInputStream的基本用法。

    我们要创建一个ZipInputStream,通常是传入一个FileInputStream作为数据源,然后,循环调用getNextEntry(),直到返回null,表示zip流结束。

    一个ZipEntry表示一个压缩文件或目录,如果是压缩文件,我们就用read()方法不断读取,直到返回-1

    try (ZipInputStream zip = new ZipInputStream(new FileInputStream(...))) {

        ZipEntry entry = null;

        while ((entry = zip.getNextEntry()) != null) {

            String name = entry.getName();

            if (!entry.isDirectory()) {

                int n;

                while ((n = zip.read()) != -1) {

                    ...

                }

            }

        }

    }

    写入zip包

    ZipOutputStream是一种FilterOutputStream,它可以直接写入内容到zip包。我们要先创建一个ZipOutputStream,通常是包装一个FileOutputStream,然后,每写入一个文件前,先调用putNextEntry(),然后用write()写入byte[]数据,写入完毕后调用closeEntry()结束这个文件的打包。

    try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(...))) {

        File[] files = ...

        for (File file : files) {

            zip.putNextEntry(new ZipEntry(file.getName()));

            zip.write(Files.readAllBytes(file.toPath()));

            zip.closeEntry();

        }

    }

    上面的代码没有考虑文件的目录结构。如果要实现目录层次结构,new ZipEntry(name)传入的name要用相对路径。

    小结

    ZipInputStream可以读取zip格式的流,ZipOutputStream可以把多份数据写入zip包;

    配合FileInputStreamFileOutputStream就可以读写zip文件。

    读取classpath资源

    很多Java程序启动的时候,都需要读取配置文件。例如,从一个.properties文件中读取配置:

    String conf = "C:\\conf\\default.properties";try (InputStream input = new FileInputStream(conf)) {

        // TODO:

    }

    这段代码要正常执行,必须在C盘创建conf目录,然后在目录里创建default.properties文件。但是,在Linux系统上,路径和Windows的又不一样。

    因此,从磁盘的固定目录读取配置文件,不是一个好的办法。

    有没有路径无关的读取文件的方式呢?

    我们知道,Java存放.class的目录或jar包也可以包含任意其他类型的文件,例如:

    • 配置文件,例如.properties
    • 图片文件,例如.jpg
    • 文本文件,例如.txt.csv
    • ……

    从classpath读取文件就可以避免不同环境下文件路径不一致的问题:如果我们把default.properties文件放到classpath中,就不用关心它的实际存放路径。

    在classpath中的资源文件,路径总是以开头,我们先获取当前的Class对象,然后调用getResourceAsStream()就可以直接从classpath读取任意的资源文件:

    try (InputStream input = getClass().getResourceAsStream("/default.properties")) {

        // TODO:

    }

    调用getResourceAsStream()需要特别注意的一点是,如果资源文件不存在,它将返回null。因此,我们需要检查返回的InputStream是否为null,如果为null,表示资源文件在classpath中没有找到:

    try (InputStream input = getClass().getResourceAsStream("/default.properties")) {

        if (input != null) {

            // TODO:

        }

    }

    如果我们把默认的配置放到jar包中,再从外部文件系统读取一个可选的配置文件,就可以做到既有默认的配置文件,又可以让用户自己修改配置:

    Properties props = new Properties();

    props.load(inputStreamFromClassPath("/default.properties"));

    props.load(inputStreamFromFile("./conf.properties"));

    这样读取配置文件,应用程序启动就更加灵活。

    小结

    把资源存储在classpath中可以避免文件路径依赖;

    Class对象的getResourceAsStream()可以从classpath中读取指定资源;

    根据classpath读取资源时,需要检查返回的InputStream是否为null

    Web开发

    从本章开始,我们就正式进入到JavaEE的领域。

    什么是JavaEE?JavaEE是Java Platform Enterprise Edition的缩写,即Java企业平台。我们前面介绍的所有基于标准JDK的开发都是JavaSE,即Java Platform Standard Edition。此外,还有一个小众不太常用的JavaME:Java Platform Micro Edition,是Java移动开发平台(非Android),它们三者关系如下:

    1. ┌────────────────┐
    2. │     JavaEE     │
    3. │┌──────────────┐│
    4. ││    JavaSE    ││
    5. ││┌────────────┐││
    6. │││   JavaME   │││
    7. ││└────────────┘││
    8. │└──────────────┘│
    9. └────────────────┘

    JavaME是一个裁剪后的“微型版”JDK,现在使用很少,我们不用管它。JavaEE也不是凭空冒出来的,它实际上是完全基于JavaSE,只是多了一大堆服务器相关的库以及API接口。所有的JavaEE程序,仍然是运行在标准的JavaSE的虚拟机上的。

    最早的JavaEE的名称是J2EE:Java 2 Platform Enterprise Edition,后来改名为JavaEE。由于Oracle将JavaEE移交给Eclipse开源组织时,不允许他们继续使用Java商标,所以JavaEE再次改名为Jakarta EE。因为这个拼写比较复杂而且难记,所以我们后面还是用JavaEE这个缩写。

    JavaEE并不是一个软件产品,它更多的是一种软件架构和设计思想。我们可以把JavaEE看作是在JavaSE的基础上,开发的一系列基于服务器的组件、API标准和通用架构。

    JavaEE最核心的组件就是基于Servlet标准的Web服务器,开发者编写的应用程序是基于Servlet API并运行在Web服务器内部的:

    1. ┌─────────────┐
    2. │┌───────────┐│
    3. ││ User App  ││
    4. │├───────────┤│
    5. ││Servlet API││
    6. │└───────────┘│
    7. │ Web Server  │
    8. ├─────────────┤
    9. │   JavaSE    │
    10. └─────────────┘

    此外,JavaEE还有一系列技术标准:

    • EJB:Enterprise JavaBean,企业级JavaBean,早期经常用于实现应用程序的业务逻辑,现在基本被轻量级框架如Spring所取代;
    • JAAS:Java Authentication and Authorization Service,一个标准的认证和授权服务,常用于企业内部,Web程序通常使用更轻量级的自定义认证;
    • JCA:JavaEE Connector Architecture,用于连接企业内部的EIS系统等;
    • JMS:Java Message Service,用于消息服务;
    • JTA:Java Transaction API,用于分布式事务;
    • JAX-WS:Java API for XML Web Services,用于构建基于XML的Web服务;
    • ...

    目前流行的基于Spring的轻量级JavaEE开发架构,使用最广泛的是Servlet和JMS,以及一系列开源组件。本章我们将详细介绍基于Servlet的Web开发。

    Web基础

    今天我们访问网站,使用App时,都是基于Web这种Browser/Server模式,简称BS架构,它的特点是,客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取Web页面,并把Web页面展示给用户即可。

    Web页面具有极强的交互性。由于Web页面是用HTML编写的,而HTML具备超强的表现力,并且,服务器端升级后,客户端无需任何部署就可以使用到新的版本,因此,BS架构升级非常容易。

    HTTP协议

    在Web应用中,浏览器请求一个URL,服务器就把生成的HTML网页发送给浏览器,而浏览器和服务器之间的传输协议是HTTP,所以:

    HTML是一种用来定义网页的文本,会HTML,就可以编写网页;

    HTTP是在网络上传输HTML的协议,用于浏览器和服务器的通信。

    HTTP协议是一个基于TCP协议之上的请求-响应协议,它非常简单,我们先使用Chrome浏览器查看新浪首页,然后选择View - Developer - Inspect Elements就可以看到HTML,切换到Network,重新加载页面,可以看到浏览器发出的每一个请求和响应

     使用Chrome浏览器可以方便地调试Web应用程序。

    对于Browser来说,请求页面的流程如下:

    1. 与服务器建立TCP连接;
    2. 发送HTTP请求;
    3. 收取HTTP响应,然后把网页在浏览器中显示出来。

    浏览器发送的HTTP请求如下:

    GET / HTTP/1.1

    Host: www.sina.com.cn

    User-Agent: Mozilla/5.0 xxx

    Accept: */*

    Accept-Language: zh-CN,zh;q=0.9,en-US;q=0.8

    其中,第一行表示使用GET请求获取路径为/的资源,并使用HTTP/1.1协议,从第二行开始,每行都是以Header: Value形式表示的HTTP头,比较常用的HTTP Header包括:

    • Host: 表示请求的主机名,因为一个服务器上可能运行着多个网站,因此,Host表示浏览器正在请求的域名;
    • User-Agent: 标识客户端本身,例如Chrome浏览器的标识类似Mozilla/5.0 ... Chrome/79,IE浏览器的标识类似Mozilla/5.0 (Windows NT ...) like Gecko
    • Accept:表示浏览器能接收的资源类型,如text/*image/*或者*/*表示所有;
    • Accept-Language:表示浏览器偏好的语言,服务器可以据此返回不同语言的网页;
    • Accept-Encoding:表示浏览器可以支持的压缩类型,例如gzip, deflate, br

    服务器的响应如下:

    HTTP/1.1 200 OK

    Content-Type: text/html

    Content-Length: 21932

    Content-Encoding: gzip

    Cache-Control: max-age=300

    ...网页数据...

    服务器响应的第一行总是版本号+空格+数字+空格+文本,数字表示响应代码,其中2xx表示成功,3xx表示重定向,4xx表示客户端引发的错误,5xx表示服务器端引发的错误。数字是给程序识别,文本则是给开发者调试使用的。常见的响应代码有:

    • 200 OK:表示成功;
    • 301 Moved Permanently:表示该URL已经永久重定向;
    • 302 Found:表示该URL需要临时重定向;
    • 304 Not Modified:表示该资源没有修改,客户端可以使用本地缓存的版本;
    • 400 Bad Request:表示客户端发送了一个错误的请求,例如参数无效;
    • 401 Unauthorized:表示客户端因为身份未验证而不允许访问该URL;
    • 403 Forbidden:表示服务器因为权限问题拒绝了客户端的请求;
    • 404 Not Found:表示客户端请求了一个不存在的资源;
    • 500 Internal Server Error:表示服务器处理时内部出错,例如因为无法连接数据库;
    • 503 Service Unavailable:表示服务器此刻暂时无法处理请求。

    从第二行开始,服务器每一行均返回一个HTTP头。服务器经常返回的HTTP Header包括:

    • Content-Type:表示该响应内容的类型,例如text/htmlimage/jpeg
    • Content-Length:表示该响应内容的长度(字节数);
    • Content-Encoding:表示该响应压缩算法,例如gzip
    • Cache-Control:指示客户端应如何缓存,例如max-age=300表示可以最多缓存300秒。

    HTTP请求和响应都由HTTP Header和HTTP Body构成,其中HTTP Header每行都以\r\n结束。如果遇到两个连续的\r\n,那么后面就是HTTP Body。浏览器读取HTTP Body,并根据Header信息中指示的Content-TypeContent-Encoding等解压后显示网页、图像或其他内容。

    通常浏览器获取的第一个资源是HTML网页,在网页中,如果嵌入了JavaScript、CSS、图片、视频等其他资源,浏览器会根据资源的URL再次向服务器请求对应的资源。

    关于HTTP协议的详细内容,请参考HTTP权威指南一书,或者Mozilla开发者网站

    我们在前面介绍的HTTP编程是以客户端的身份去请求服务器资源。现在,我们需要以服务器的身份响应客户端请求,编写服务器程序来处理客户端请求通常就称之为Web开发。

    编写HTTP Server

    我们来看一下如何编写HTTP Server。一个HTTP Server本质上是一个TCP服务器,我们先用TCP编程的多线程实现的服务器端框架:

    public class Server {

        public static void main(String[] args) throws IOException {

            ServerSocket ss = new ServerSocket(8080); // 监听指定端口

            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;

        }

        public void run() {

            try (InputStream input = this.sock.getInputStream()) {

                try (OutputStream output = this.sock.getOutputStream()) {

                    handle(input, output);

                }

            } catch (Exception e) {

            } finally {

                try {

                    this.sock.close();

                } catch (IOException ioe) {

                }

                System.out.println("client disconnected.");

            }

        }

        private void handle(InputStream input, OutputStream output) throws IOException {

            var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));

            var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));

            // TODO: 处理HTTP请求

        }

    }

    只需要在handle()方法中,用Reader读取HTTP请求,用Writer发送HTTP响应,即可实现一个最简单的HTTP服务器。编写代码如下:

    private void handle(InputStream input, OutputStream output) throws IOException {

        System.out.println("Process new http request...");

        var reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));

        var writer = new BufferedWriter(new OutputStreamWriter(output, StandardCharsets.UTF_8));

        // 读取HTTP请求:

        boolean requestOk = false;

        String first = reader.readLine();

        if (first.startsWith("GET / HTTP/1.")) {

            requestOk = true;

        }

        for (;;) {

            String header = reader.readLine();

            if (header.isEmpty()) { // 读取到空行时, HTTP Header读取完毕

                break;

            }

            System.out.println(header);

        }

        System.out.println(requestOk ? "Response OK" : "Response Error");

        if (!requestOk) {

            // 发送错误响应:

            writer.write("HTTP/1.0 404 Not Found\r\n");

            writer.write("Content-Length: 0\r\n");

            writer.write("\r\n");

            writer.flush();

        } else {

            // 发送成功响应:

            String data = "

    Hello, world!

    ";

            int length = data.getBytes(StandardCharsets.UTF_8).length;

            writer.write("HTTP/1.0 200 OK\r\n");

            writer.write("Connection: close\r\n");

            writer.write("Content-Type: text/html\r\n");

            writer.write("Content-Length: " + length + "\r\n");

            writer.write("\r\n"); // 空行标识Header和Body的分隔

            writer.write(data);

            writer.flush();

        }

    }

    这里的核心代码是,先读取HTTP请求,这里我们只处理GET /的请求。当读取到空行时,表示已读到连续两个\r\n,说明请求结束,可以发送响应。发送响应的时候,首先发送响应代码HTTP/1.0 200 OK表示一个成功的200响应,使用HTTP/1.0协议,然后,依次发送Header,发送完Header后,再发送一个空行标识Header结束,紧接着发送HTTP Body,在浏览器输入http://local.liaoxuefeng.com:8080/就可以看到响应页面

    HTTP目前有多个版本,1.0是早期版本,浏览器每次建立TCP连接后,只发送一个HTTP请求并接收一个HTTP响应,然后就关闭TCP连接。由于创建TCP连接本身就需要消耗一定的时间,因此,HTTP 1.1允许浏览器和服务器在同一个TCP连接上反复发送、接收多个HTTP请求和响应,这样就大大提高了传输效率。

    我们注意到HTTP协议是一个请求-响应协议,它总是发送一个请求,然后接收一个响应。能不能一次性发送多个请求,然后再接收多个响应呢?HTTP 2.0可以支持浏览器同时发出多个请求,但每个请求需要唯一标识,服务器可以不按请求的顺序返回多个响应,由浏览器自己把收到的响应和请求对应起来。可见,HTTP 2.0进一步提高了传输效率,因为浏览器发出一个请求后,不必等待响应,就可以继续发下一个请求。

    HTTP 3.0为了进一步提高速度,将抛弃TCP协议,改为使用无需创建连接的UDP协议,目前HTTP 3.0仍然处于实验阶段。

    小结

    使用B/S架构时,总是通过HTTP协议实现通信;

    Web开发通常是指开发服务器端的Web应用程序。

    ​​​​​​​

    Servlet入门

    在上一节中,我们看到,编写HTTP服务器其实是非常简单的,只需要先编写基于多线程的TCP服务,然后在一个TCP连接中读取HTTP请求,发送HTTP响应即可。

    但是,要编写一个完善的HTTP服务器,以HTTP/1.1为例,需要考虑的包括:

    • 识别正确和错误的HTTP请求;
    • 识别正确和错误的HTTP头;
    • 复用TCP连接;
    • 复用线程;
    • IO异常处理;
    • ...

    这些基础工作需要耗费大量的时间,并且经过长期测试才能稳定运行。如果我们只需要输出一个简单的HTML页面,就不得不编写上千行底层代码,那就根本无法做到高效而可靠地开发。

    因此,在JavaEE平台上,处理TCP连接,解析HTTP协议这些底层工作统统扔给现成的Web服务器去做,我们只需要把自己的应用程序跑在Web服务器上。为了实现这一目的,JavaEE提供了Servlet API,我们使用Servlet API编写自己的Servlet来处理HTTP请求,Web服务器实现Servlet API接口,实现底层功能:

    1.                  ┌───────────┐
    2.                  │My Servlet │
    3.                  ├───────────┤
    4.                  │Servlet API│
    5. ┌───────┐  HTTP  ├───────────┤
    6. │Browser│<──────>│Web Server │
    7. └───────┘        └───────────┘

    我们来实现一个最简单的Servlet:

    // WebServlet注解表示这是一个Servlet,并映射到地址/:@WebServlet(urlPatterns = "/")public class HelloServlet extends HttpServlet {

        protected void doGet(HttpServletRequest req, HttpServletResponse resp)

                throws ServletException, IOException {

            // 设置响应类型:

            resp.setContentType("text/html");

            // 获取输出流:

            PrintWriter pw = resp.getWriter();

            // 写入响应:

            pw.write("

    Hello, world!

    ");

            // 最后不要忘记flush强制输出:

            pw.flush();

        }

    }

    一个Servlet总是继承自HttpServlet,然后覆写doGet()doPost()方法。注意到doGet()方法传入了HttpServletRequestHttpServletResponse两个对象,分别代表HTTP请求和响应。我们使用Servlet API时,并不直接与底层TCP交互,也不需要解析HTTP协议,因为HttpServletRequestHttpServletResponse就已经封装好了请求和响应。以发送响应为例,我们只需要设置正确的响应类型,然后获取PrintWriter,写入响应即可。

    现在问题来了:Servlet API是谁提供?

    Servlet API是一个jar包,我们需要通过Maven来引入它,才能正常编译。编写pom.xml文件如下:

    xmlns="http://maven.apache.org/POM/4.0.0"

        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">

        4.0.0

        com.itranswarp.learnjava

        web-servlet-hello

        war

        1.0-SNAPSHOT

        

            UTF-8

            UTF-8

            17

            17

            17

        

        

            

                jakarta.servlet

                jakarta.servlet-api

                5.0.0

                provided

            

        

        

            hello

        

    注意到这个pom.xml与前面我们讲到的普通Java程序有个区别,打包类型不是jar,而是war,表示Java Web Application Archive:

    war

    引入的Servlet API如下:

        jakarta.servlet

        jakarta.servlet-api

        5.0.0

        provided

    注意到指定为provided,表示编译时使用,但不会打包到.war文件中,因为运行期Web服务器本身已经提供了Servlet API相关的jar包。

    Servlet版本

    要务必注意servlet-api的版本。4.0及之前的servlet-api由Oracle官方维护,引入的依赖项是javax.servlet:javax.servlet-api,编写代码时引入的包名为:

    import javax.servlet.*;

    而5.0及以后的servlet-api由Eclipse开源社区维护,引入的依赖项是jakarta.servlet:jakarta.servlet-api,编写代码时引入的包名为:

    import jakarta.servlet.*;

    教程采用最新的jakarta.servlet:5.0.0版本,但对于很多仅支持Servlet 4.0版本的框架来说,例如Spring 5,我们就只能使用javax.servlet:4.0.0版本,这一点针对不同项目要特别注意。

     引入不同的Servlet API版本,编写代码时导入的相关API的包名是不同的。

    整个工程结构如下:

    web-servlet-hello

    ├── pom.xml

    └── src

        └── main

            ├── java

            │   └── com

            │       └── itranswarp

            │           └── learnjava

            │               └── servlet

            │                   └── HelloServlet.java

            ├── resources

            └── webapp

    目录webapp目前为空,如果我们需要存放一些资源文件,则需要放入该目录。有的同学可能会问,webapp目录下是否需要一个/WEB-INF/web.xml配置文件?这个配置文件是低版本Servlet必须的,但是高版本Servlet已不再需要,所以无需该配置文件。

    运行Maven命令mvn clean package,在target目录下得到一个hello.war文件,这个文件就是我们编译打包后的Web应用程序。

     如果执行package命令遇到Execution default-war of goal org.apache.maven.plugins:maven-war-plugin:2.2:war failed错误时,可手动指定maven-war-plugin最新版本3.3.2,参考练习工程的pom.xml。

    现在问题又来了:我们应该如何运行这个war文件?

    普通的Java程序是通过启动JVM,然后执行main()方法开始运行。但是Web应用程序有所不同,我们无法直接运行war文件,必须先启动Web服务器,再由Web服务器加载我们编写的HelloServlet,这样就可以让HelloServlet处理浏览器发送的请求。

    因此,我们首先要找一个支持Servlet API的Web服务器。常用的服务器有:

    • Tomcat:由Apache开发的开源免费服务器;
    • Jetty:由Eclipse开发的开源免费服务器;
    • GlassFish:一个开源的全功能JavaEE服务器。

    还有一些收费的商用服务器,如Oracle的WebLogic,IBM的WebSphere

    无论使用哪个服务器,只要它支持Servlet API 5.0(因为我们引入的Servlet版本是5.0),我们的war包都可以在上面运行。这里我们选择使用最广泛的开源免费的Tomcat服务器。

    要运行我们的hello.war,首先要下载Tomcat服务器,解压后,把hello.war复制到Tomcat的webapps目录下,然后切换到bin目录,执行startup.shstartup.bat启动Tomcat服务器:

    $ ./startup.sh

    Using CATALINA_BASE:   .../apache-tomcat-10.1.x

    Using CATALINA_HOME:   .../apache-tomcat-10.1.x

    Using CATALINA_TMPDIR: .../apache-tomcat-10.1.x/temp

    Using JRE_HOME:        .../jdk-17.jdk/Contents/Home

    Using CLASSPATH:       .../apache-tomcat-10.1.x/bin/bootstrap.jar:...

    Tomcat started.

    在浏览器输入http://localhost:8080/hello/即可看到HelloServlet的输出

    细心的童鞋可能会问,为啥路径是/hello/而不是/?因为一个Web服务器允许同时运行多个Web App,而我们的Web App叫hello,因此,第一级目录/hello表示Web App的名字,后面的/才是我们在HelloServlet中映射的路径。

    那能不能直接使用/而不是/hello/?毕竟/比较简洁。

    答案是肯定的。先关闭Tomcat(执行shutdown.shshutdown.bat),然后删除Tomcat的webapps目录下的所有文件夹和文件,最后把我们的hello.war复制过来,改名为ROOT.war,文件名为ROOT的应用程序将作为默认应用,启动后直接访问http://localhost:8080/即可。

    实际上,类似Tomcat这样的服务器也是Java编写的,启动Tomcat服务器实际上是启动Java虚拟机,执行Tomcat的main()方法,然后由Tomcat负责加载我们的.war文件,并创建一个HelloServlet实例,最后以多线程的模式来处理HTTP请求。如果Tomcat服务器收到的请求路径是/(假定部署文件为ROOT.war),就转发到HelloServlet并传入HttpServletRequestHttpServletResponse两个对象。

    因为我们编写的Servlet并不是直接运行,而是由Web服务器加载后创建实例运行,所以,类似Tomcat这样的Web服务器也称为Servlet容器。

    Tomcat版本

    由于Servlet版本分为<=4.0和>=5.0两种,所以,要根据使用的Servlet版本选择正确的Tomcat版本。从Tomcat版本页可知:

    • 使用Servlet<=4.0时,选择Tomcat 9.x或更低版本;
    • 使用Servlet>=5.0时,选择Tomcat 10.x或更高版本。

    运行本节代码需要使用Tomcat 10.x版本。

    在Servlet容器中运行的Servlet具有如下特点:

    • 无法在代码中直接通过new创建Servlet实例,必须由Servlet容器自动创建Servlet实例;
    • Servlet容器只会给每个Servlet类创建唯一实例;
    • Servlet容器会使用多线程执行doGet()doPost()方法。

    复习一下Java多线程的内容,我们可以得出结论:

    • 在Servlet中定义的实例变量会被多个线程同时访问,要注意线程安全;
    • HttpServletRequestHttpServletResponse实例是由Servlet容器传入的局部变量,它们只能被当前线程访问,不存在多个线程访问的问题;
    • doGet()doPost()方法中,如果使用了ThreadLocal,但没有清理,那么它的状态很可能会影响到下次的某个请求,因为Servlet容器很可能用线程池实现线程复用。

    因此,正确编写Servlet,要清晰理解Java的多线程模型,需要同步访问的必须同步。​​​​​​​

    小结

    编写Web应用程序就是编写Servlet处理HTTP请求;

    Servlet API提供了HttpServletRequestHttpServletResponse两个高级接口来封装HTTP请求和响应;

    Web应用程序必须按固定结构组织并打包为.war文件;

    需要启动Web服务器来加载我们的war包来运行Servlet。

    Servlet开发

    在上一节中,我们看到,一个完整的Web应用程序的开发流程如下:

    1. 编写Servlet;
    2. 打包为war文件;
    3. 复制到Tomcat的webapps目录下;
    4. 启动Tomcat。

    这个过程是不是很繁琐?如果我们想在IDE中断点调试,还需要打开Tomcat的远程调试端口并且连接上去。

    许多初学者经常卡在如何在IDE中启动Tomcat并加载webapp,更不要说断点调试了。

    我们需要一种简单可靠,能直接在IDE中启动并调试webapp的方法。

    因为Tomcat实际上也是一个Java程序,我们看看Tomcat的启动流程:

    1. 启动JVM并执行Tomcat的main()方法;
    2. 加载war并初始化Servlet;
    3. 正常服务。

    启动Tomcat无非就是设置好classpath并执行Tomcat某个jar包的main()方法,我们完全可以把Tomcat的jar包全部引入进来,然后自己编写一个main()方法,先启动Tomcat,然后让它加载我们的webapp就行。

    我们新建一个web-servlet-embedded工程,编写pom.xml如下:

    xmlns="http://maven.apache.org/POM/4.0.0"

        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"

        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

        4.0.0

        com.itranswarp.learnjava

        web-servlet-embedded

        1.0-SNAPSHOT

        war

        

            UTF-8

            UTF-8

            17

            17

            17

            10.1.1

        

        

            

                org.apache.tomcat.embed

                tomcat-embed-core

                ${tomcat.version}

                provided

            

            

                org.apache.tomcat.embed

                tomcat-embed-jasper

                ${tomcat.version}

                provided

            

        

    其中,类型仍然为war,引入依赖tomcat-embed-coretomcat-embed-jasper,引入的Tomcat版本10.1.1

    不必引入Servlet API,因为引入Tomcat依赖后自动引入了Servlet API。因此,我们可以正常编写Servlet如下:

    @WebServlet(urlPatterns = "/")public class HelloServlet extends HttpServlet {

        protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

            resp.setContentType("text/html");

            String name = req.getParameter("name");

            if (name == null) {

                name = "world";

            }

            PrintWriter pw = resp.getWriter();

            pw.write("

    Hello, " + name + "!

    ");

            pw.flush();

        }

    }

    然后,我们编写一个main()方法,启动Tomcat服务器:

    public class Main {

        public static void main(String[] args) throws Exception {

            // 启动Tomcat:

            Tomcat tomcat = new Tomcat();

            tomcat.setPort(Integer.getInteger("port", 8080));

            tomcat.getConnector();

            // 创建webapp:

            Context ctx = tomcat.addWebapp("", new File("src/main/webapp").getAbsolutePath());

            WebResourceRoot resources = new StandardRoot(ctx);

            resources.addPreResources(

                    new DirResourceSet(resources, "/WEB-INF/classes", new File("target/classes").getAbsolutePath(), "/"));

            ctx.setResources(resources);

            tomcat.start();

            tomcat.getServer().await();

        }

    }

    这样,我们直接运行main()方法,即可启动嵌入式Tomcat服务器,然后,通过预设的tomcat.addWebapp("", new File("src/main/webapp"),Tomcat会自动加载当前工程作为根webapp,可直接在浏览器访问http://localhost:8080/

    通过main()方法启动Tomcat服务器并加载我们自己的webapp有如下好处:

    1. 启动简单,无需下载Tomcat或安装任何IDE插件;
    2. 调试方便,可在IDE中使用断点调试;
    3. 使用Maven创建war包后,也可以正常部署到独立的Tomcat服务器中。

    生成可执行war包

    如果要生成可执行的war包,用java -jar xxx.war启动,则需要把Tomcat的依赖项的去掉,然后配置maven-war-plugin如下:

    ...>

        ...

    hello

    org.apache.maven.plugins

    maven-war-plugin

    3.3.2

    ${project.build.directory}/classes

    true

    true

    tmp-webapp/WEB-INF/lib/

    com.itranswarp.learnjava.Main

    生成的war包结构如下:

    hello.war

    ├── META-INF

    │   ├── MANIFEST.MF

    │   └── maven

    │       └── ...

    ├── WEB-INF

    │   ├── classes

    │   ├── lib

    │   │   ├── ecj-3.18.0.jar

    │   │   ├── tomcat-annotations-api-10.1.1.jar

    │   │   ├── tomcat-embed-core-10.1.1.jar

    │   │   ├── tomcat-embed-el-10.1.1.jar

    │   │   ├── tomcat-embed-jasper-10.1.1.jar

    │   │   └── web-servlet-embedded-1.0-SNAPSHOT.jar

    │   └── web.xml

    └── com

        └── itranswarp

            └── learnjava

                ├── Main.class

                ├── TomcatRunner.class

                └── servlet

                    └── HelloServlet.class

    之所以要把编译后的classes复制到war包根目录,是因为用java -jar hello.war启动时,JVM的Class Loader不会查找WEB-INF/lib的jar包,而是直接从hello.war的根目录查找。MANIFEST.MF生成的内容如下:

    Main-Class: com.itranswarp.learnjava.Main

    Class-Path: tmp-webapp/WEB-INF/lib/tomcat-embed-core-10.1.1.jar tmp-weba

     pp/WEB-INF/lib/tomcat-annotations-api-10.1.1.jar tmp-webapp/WEB-INF/lib

     /tomcat-embed-jasper-10.1.1.jar tmp-webapp/WEB-INF/lib/tomcat-embed-el-

     10.1.1.jar tmp-webapp/WEB-INF/lib/ecj-3.18.0.jar

    注意到Class-Path的路径,这里定义的Class-Path相当于java -cp指定的Classpath,JVM不会在一个jar包中查找jar包内的jar包,它只会在文件系统中搜索,因此,我们要修改main()方法,在执行main()方法时,先自解压war包,再启动Tomcat:

    public class Main {

        public static void main(String[] args) throws Exception {

            // 判定是否从jar/war启动:

            String jarFile = Main.class.getProtectionDomain().getCodeSource().getLocation().getFile();

            boolean isJarFile = jarFile.endsWith(".war") || jarFile.endsWith(".jar");

            // 定位webapp根目录:

            String webDir = isJarFile ? "tmp-webapp" : "src/main/webapp";

            if (isJarFile) {

                // 解压到tmp-webapp:

                Path baseDir = Paths.get(webDir).normalize().toAbsolutePath();

                if (Files.isDirectory(baseDir)) {

                    Files.delete(baseDir);

                }

                Files.createDirectories(baseDir);

                System.out.println("extract to: " + baseDir);

                try (JarFile jar = new JarFile(jarFile)) {

                    List entries = jar.stream().sorted(Comparator.comparing(JarEntry::getName))

                            .collect(Collectors.toList());

                    for (JarEntry entry : entries) {

                        Path res = baseDir.resolve(entry.getName());

                        if (!entry.isDirectory()) {

                            System.out.println(res);

                            Files.createDirectories(res.getParent());

                            Files.copy(jar.getInputStream(entry), res);

                        }

                    }

                }

                // JVM退出时自动删除tmp-webapp:

                Runtime.getRuntime().addShutdownHook(new Thread(() -> {

                    try {

                        Files.walk(baseDir).sorted(Comparator.reverseOrder()).map(Path::toFile).forEach(File::delete);

                    } catch (IOException e) {

                        e.printStackTrace();

                    }

                }));

            }

            // 启动Tomcat:

            TomcatRunner.run(webDir, isJarFile ? "tmp-webapp" : "target/classes");

        }

    }

    // Tomcat启动类:class TomcatRunner {

        public static void run(String webDir, String baseDir) throws Exception {

            Tomcat tomcat = new Tomcat();

            tomcat.setPort(Integer.getInteger("port", 8080));

            tomcat.getConnector();

            Context ctx = tomcat.addWebapp("", new File(webDir).getAbsolutePath());

            WebResourceRoot resources = new StandardRoot(ctx);

            resources.addPreResources(new DirResourceSet(resources, "/WEB-INF/classes", new File(baseDir).getAbsolutePath(), "/"));

            ctx.setResources(resources);

            tomcat.start();

            tomcat.getServer().await();

        }

    }

    现在,执行java -jar hello.war时,JVM先定位hello.warMain类,运行main(),自动解压后,文件系统目录如下:

    ├── hello.war

    └── tmp-webapp

        └── WEB-INF

            ├── lib

            │   ├── ecj-3.18.0.jar

            │   ├── tomcat-annotations-api-10.1.1.jar

            │   ├── tomcat-embed-core-10.1.1.jar

            │   ├── tomcat-embed-el-10.1.1.jar

            │   ├── tomcat-embed-jasper-10.1.1.jar

            │   └── web-servlet-embedded-1.0-SNAPSHOT.jar

            └── web.xml

    解压后的目录结构和我们在MANIFEST.MF中设定的Class-Path一致,因此,JVM能顺利加载Tomcat的jar包,然后运行Tomcat,启动Web App。

    编写可执行的jar或者war需要注意的几点:

    • 必须在MANIFEST.MF中指定Main-ClassClass-Path
    • Main必须能在jar/war包的根目录下被JVM的Class Loader加载;
    • Main负责解压jar/war,解压后的目录结构与MANIFEST.MF中设定的Class-Path一致;
    • Main不能引用任何解压后才能被加载的类,例如org.apache.catalina.startup.Tomcat

    对SpringBoot有所了解的童鞋可能知道,SpringBoot也支持在main()方法中一行代码直接启动Tomcat,并且还能方便地更换成Jetty等其他服务器。它的启动方式和我们介绍的是基本一样的,后续涉及到SpringBoot的部分我们还会详细讲解。

    小结

    开发Servlet时,推荐使用main()方法启动嵌入式Tomcat服务器并加载当前工程的webapp,便于开发调试,且不影响打包部署,能极大地提升开发效率

  • 相关阅读:
    getBytes方法
    LabVIEW中将枚举与条件结构一起使用
    HarmonyOS入门开发(二) Toast使用
    2023云计算发展
    基于SSM农产品商城系统
    云计算与边缘计算:有何不同?
    图像几何变换
    xxl-job 快速使用
    FreeSWITCH 与 Asterisk(译)
    (附源码)计算机毕业设计SSM竞赛报名管理系统
  • 原文地址:https://blog.csdn.net/2301_76141427/article/details/138821530