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种功能的组合,又需要更多的子类:

                          ┌─────────────────┐
                          │ FileInputStream │
                          └─────────────────┘
                                   ▲
             ┌───────────┬─────────┼─────────┬───────────┐
             │           │         │         │           │
┌───────────────────────┐│┌─────────────────┐│┌─────────────────────┐
│BufferedFileInputStream│││DigestInputStream│││CipherFileInputStream│
└───────────────────────┘│└─────────────────┘│└─────────────────────┘
                         │                   │
    ┌─────────────────────────────┐ ┌─────────────────────────────┐
    │BufferedDigestFileInputStream│ │BufferedCipherFileInputStream│
    └─────────────────────────────┘ └─────────────────────────────┘

这还只是针对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来引用它,就可以正常读取:

┌─────────────────────────┐
│GZIPInputStream          │
│┌───────────────────────┐│
││BufferedFileInputStream││
││┌─────────────────────┐││
│││   FileInputStream   │││
││└─────────────────────┘││
│└───────────────────────┘│
└─────────────────────────┘

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

                 ┌─────────────┐
                 │ InputStream │
                 └─────────────┘
                       ▲ ▲
┌────────────────────┐ │ │ ┌─────────────────┐
│  FileInputStream   │─┤ └─│FilterInputStream│
└────────────────────┘ │   └─────────────────┘
┌────────────────────┐ │     ▲ ┌───────────────────┐
│ByteArrayInputStream│─┤     ├─│BufferedInputStream│
└────────────────────┘ │     │ └───────────────────┘
┌────────────────────┐ │     │ ┌───────────────────┐
│ ServletInputStream │─┘     ├─│  DataInputStream  │
└────────────────────┘       │ └───────────────────┘
                             │ ┌───────────────────┐
                             └─│CheckedInputStream │
                               └───────────────────┘

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

                  ┌─────────────┐
                  │OutputStream │
                  └─────────────┘
                        ▲ ▲
┌─────────────────────┐ │ │ ┌──────────────────┐
│  FileOutputStream   │─┤ └─│FilterOutputStream│
└─────────────────────┘ │   └──────────────────┘
┌─────────────────────┐ │     ▲ ┌────────────────────┐
│ByteArrayOutputStream│─┤     ├─│BufferedOutputStream│
└─────────────────────┘ │     │ └────────────────────┘
┌─────────────────────┐ │     │ ┌────────────────────┐
│ ServletOutputStream │─┘     ├─│  DataOutputStream  │
└─────────────────────┘       │ └────────────────────┘
                              │ ┌────────────────────┐
                              └─│CheckedOutputStream │
                                └────────────────────┘

编写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包的内容:

┌───────────────────┐
│    InputStream    │
└───────────────────┘
          ▲
          │
┌───────────────────┐
│ FilterInputStream │
└───────────────────┘
          ▲
          │
┌───────────────────┐
│InflaterInputStream│
└───────────────────┘
          ▲
          │
┌───────────────────┐
│  ZipInputStream   │
└───────────────────┘
          ▲
          │
┌───────────────────┐
│  JarInputStream   │
└───────────────────┘

另一个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),它们三者关系如下:

┌────────────────┐
│     JavaEE     │
│┌──────────────┐│
││    JavaSE    ││
││┌────────────┐││
│││   JavaME   │││
││└────────────┘││
│└──────────────┘│
└────────────────┘

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服务器内部的:

┌─────────────┐
│┌───────────┐│
││ User App  ││
│├───────────┤│
││Servlet API││
│└───────────┘│
│ Web Server  │
├─────────────┤
│   JavaSE    │
└─────────────┘

此外,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

<html>...网页数据...

服务器响应的第一行总是版本号+空格+数字+空格+文本,数字表示响应代码,其中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 = "<html><body><h1>Hello, world!</h1></body></html>";

        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接口,实现底层功能:

                 ┌───────────┐
                 │My Servlet │
                 ├───────────┤
                 │Servlet API│
┌───────┐  HTTP  ├───────────┤
│Browser│<──────>│Web Server │
└───────┘        └───────────┘

我们来实现一个最简单的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("<h1>Hello, world!</h1>");

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

        pw.flush();

    }

}

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

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

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

<project 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">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.itranswarp.learnjava</groupId>

    <artifactId>web-servlet-hello</artifactId>

    <packaging>war</packaging>

    <version>1.0-SNAPSHOT</version>

    <properties>

        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

        <maven.compiler.source>17</maven.compiler.source>

        <maven.compiler.target>17</maven.compiler.target>

        <java.version>17</java.version>

    </properties>

    <dependencies>

        <dependency>

            <groupId>jakarta.servlet</groupId>

            <artifactId>jakarta.servlet-api</artifactId>

            <version>5.0.0</version>

            <scope>provided</scope>

        </dependency>

    </dependencies>

    <build>

        <finalName>hello</finalName>

    </build></project>

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

<packaging>war</packaging>

引入的Servlet API如下:

<dependency>

    <groupId>jakarta.servlet</groupId>

    <artifactId>jakarta.servlet-api</artifactId>

    <version>5.0.0</version>

    <scope>provided</scope></dependency>

注意到<scope>指定为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如下:

<project 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">

    <modelVersion>4.0.0</modelVersion>

    <groupId>com.itranswarp.learnjava</groupId>

    <artifactId>web-servlet-embedded</artifactId>

    <version>1.0-SNAPSHOT</version>

    <packaging>war</packaging>

    <properties>

        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>

        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>

        <maven.compiler.source>17</maven.compiler.source>

        <maven.compiler.target>17</maven.compiler.target>

        <java.version>17</java.version>

        <tomcat.version>10.1.1</tomcat.version>

    </properties>

    <dependencies>

        <dependency>

            <groupId>org.apache.tomcat.embed</groupId>

            <artifactId>tomcat-embed-core</artifactId>

            <version>${tomcat.version}</version>

            <scope>provided</scope>

        </dependency>

        <dependency>

            <groupId>org.apache.tomcat.embed</groupId>

            <artifactId>tomcat-embed-jasper</artifactId>

            <version>${tomcat.version}</version>

            <scope>provided</scope>

        </dependency>

    </dependencies></project>

其中,<packaging>类型仍然为war,引入依赖tomcat-embed-coretomcat-embed-jasper,引入的Tomcat版本<tomcat.version>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("<h1>Hello, " + name + "!</h1>");

        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的依赖项的<scope>去掉,然后配置maven-war-plugin如下:

<project ...>

    ...

<build>

<finalName>hello</finalName>

<plugins>

<plugin>

<groupId>org.apache.maven.plugins</groupId>

<artifactId>maven-war-plugin</artifactId>

<version>3.3.2</version>

<configuration>

<!-- 复制classes到war包根目录 -->

<webResources>

<resource>

<directory>${project.build.directory}/classes</directory>

</resource>

</webResources>

<archiveClasses>true</archiveClasses>

<archive>

<manifest>

<!-- 添加Class-Path -->

<addClasspath>true</addClasspath>

<!-- Classpath前缀 -->

<classpathPrefix>tmp-webapp/WEB-INF/lib/</classpathPrefix>

<!-- main启动类 -->

<mainClass>com.itranswarp.learnjava.Main</mainClass>

</manifest>

</archive>

</configuration>

</plugin>

</plugins>

</build></project>

生成的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<JarEntry> 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(),自动解压后,文件系统目录如下:

<work>

├── 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,便于开发调试,且不影响打包部署,能极大地提升开发效率

  • 59
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值