在写后端业务之前,先明白Web开发的发展

为什么要写这个呢?因为室友在秋招简历上写了个关于HTTP服务器的项目,在很多次面试中都被问到了,我对此甚是好奇,加上有时面试的时候也会被问到Spring这个常被用来作为Web开发的框架,觉得对于Web开发这些还是有必须要好好梳理一次。

主要参考:

廖雪峰老师的Java教程 本篇的内容很多都是来自廖老师,额外加上了个人理解,仅作个人记录。

实现一个Web服务器

当我们访问一个网页的时候,其实是我们的浏览器与远程的Web服务器建立了网络连接,现在一般是HTTPS连接,关于网络连接具体的流程是我之前写的另一篇文章,现在的网页一般是HTML的格式,HTML即超文本标记语言,是一种用于创建网页的标准标记语言,它一般和CSS、JavaScript一起来显示完整的网页,我们本地发送了GET请求拿到的第一个资源往往就是对应网页的HTML。

简单来说,对于浏览器而言,展示页面的流程大致如下:

  1. 与Web服务器建立连接
  2. 发送HTTP GET请求
  3. 将请求结果在浏览器渲染出来

上面是从一个客户端的角度来描述Web请求的整个过程。今天主要想说的是在作为Web服务器,Web服务器需要去处理客户端的请求,我们要怎么去做呢?

多线程实现Web服务器

下面的代码来自 廖雪峰老师的Java教程

最简单的方式,监听特定端口,来了一个请求之后,新启一个线程去处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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) {
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服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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();
}
}

Servlet开发

从上面的例子可以看到,除开特定的业务和展示页面外,编写一个HTTP服务要做的事其实总体来讲是相对固定的,都需要:

  • 建立网络连接
  • 监听特定端口
  • 识别HTTP请求和HTTP头
  • 复用TCP连接
  • 异常处理

无论是一个建立的 HTML 还是一个复杂的展示页面都需要实现这些步骤,完全可以把这些固定的步骤都封装起来,只留下处理请求部门的接口就好了,在 Java 中,这个接口就是 Servlet。至于其它部分,处理 TCP 连接啊,解析HTTP协议啊都可以交给现成的Web服务器去做。

如果还不理解 Servlet 是怎样的一个概念,推荐阅读这个:

servlet的本质是什么,它是如何工作的? - bravo1988的回答 - 知乎 https://www.zhihu.com/question/21416727/answer/690289895

一个简单的 Servlet 例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 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 来自 javax 包,它也是 Java 标准的一部分,属于核心库,但是没有直接放在 jdk 中,这是为了避免 jdk 太大。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<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>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<java.version>11</java.version>
</properties>

<dependencies>
<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.0</version>
<scope>provided</scope>
</dependency>
</dependencies>

<build>
<finalName>hello</finalName>
</build>
</project>

这个pom.xml与普通 Java 程序有个区别,打包类型不是jar,而是war,表示 Java Web Application Archive。

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

还需要在工程目录下创建一个web.xml描述文件,放到src/main/webapp/WEB-INF目录下(固定目录结构,不要修改路径,注意大小写)。文件内容可以固定如下:

1
2
3
4
5
6
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd">
<web-app>
<display-name>Archetype Created Web Application</display-name>
</web-app>

整个工程结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
web-servlet-hello
├── pom.xml
└── src
└── main
├── java
│ └── com
│ └── itranswarp
│ └── learnjava
│ └── servlet
│ └── HelloServlet.java
├── resources
└── webapp
└── WEB-INF
└── web.xml

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

我们应该如何运行这个war文件?

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

因此,我们首先要找一个支持Servlet API的Web服务器。目前最常用的开源免费的 Web服务器是 Tomcat,它是由Apache开发的开源免费服务器。还有 Jetty,GlassFish,收费的 WebLogic 等等都可以作为 Web服务器。无论哪一个服务器,只要它支持Servlet API 4.0(因为我们引入的Servlet版本是4.0),我们的war包都可以在上面运行。

将我们写的程序放到 Tomcat 的webapps 目录下,再启动 Tomcat服务器,我们就可以在浏览器上看到我们的展示页面啦~

1
2
3
4
浏览器键入 localhost:8080/hello

这里路径是 /hello 而不是 / 的原因是我们的Web App叫 hello
如果改成 ROOT.war 的话,就会被映射到 /

大功告成!页面展示也有啦~

等等!我的 main 方法呢?Tomcat 到底是怎么调用我的 Servlet 的?Tomcat 里面真的和我写的多线程是一样的吗?

如果理解了 Servlet 的话,很容易看出,Servlet 只是完整 Web 程序的一部分,只是一个拼图,Tomcat 这类 Web 服务器将我们写的代码拼上去就完事儿,main方法当然也是 Tomcat 的main方法,一个拼图要什么 main 方法。至于怎么拼?反射!其实大部分框架都是如此,框架完整了大部分的工作,我们只需要实现其中的一小部分就好了,框架提高了遍历,也遮蔽了原理,甚至改变了我们编程的流程。

写了一个 Servlet 程序,很简单很方便,不需要我去和网络打交道了,简单的实现业务就可以在浏览器看到漂亮的页面了,很棒!但是,总有种奇怪的感觉。

在IDE中启动Tomcat

Tomcat 还提供了集成在IDEA启动的 tomcat-embed 包:

1
2
3
4
5
6
7
8
9
10
11
12
<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>

引入 Tomcat依赖 之后不必再引入 Servlet API 的依赖。在 IDE 中启动 Tomcat 可以很方面的进行调试,并且使用 Maven 打包之后, war 也可以放到独立的 Tomcat 服务器中。

JSP是什么

无论是在多线程实现 Web服务器 还是在 Servlet 中,我们都写了很多这样的代码:

1
2
3
4
5
6
7
PrintWriter pw = resp.getWriter();
pw.write("<html>");
pw.write("<body>");
pw.write("<h1>Welcome, " + name + "!</h1>");
pw.write("</body>");
pw.write("</html>");
pw.flush();

非常难受,因为不但要正确编写HTML,还需要插入各种变量。如果想用这种方式写一个复杂的HTML网页,那么是非常困难的。要解决这一问题,我们可以用 JSP。

JSP(全称JavaServer Pages)是由 Sun 公司主导创建的一种动态网页技术标准。它也是 Java EE 的一部分,可以根据请求动态的生成 HTML其他格式文档的 Web 网页。

文件必须放到/src/main/webapp下,内容和 HTML 很像,需要插入变量的地方可以使用特殊指令<% ... %>

写一个hello.jsp例子,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<html>
<head>
<title>Hello World - JSP</title>
</head>
<body>
<%-- JSP Comment --%>
<h1>Hello World!</h1>
<p>
<%
out.println("Your IP address is ");
%>
<span style="color:red">
<%= request.getRemoteAddr() %>
</span>
</p>
</body>
</html>

整个JSP的内容实际上是一个HTML,但是稍有不同:

  • 包含在<%----%>之间的是JSP的注释,它们会被完全忽略;
  • 包含在<%%>之间的是Java代码,可以编写任意Java代码;
  • 如果使用<%= xxx %>则可以快捷输出一个变量的值。

访问 JSP 页面时,直接指定完整路径。例如,http://localhost:8080/hello.jsp

JSP 和 Servlet 本质上没有区别,因为JSP在执行前首先被编译成一个Servlet。在Tomcat的临时目录下,可以找到一个hello_jsp.java的源文件,这个文件就是Tomcat把JSP自动转换成的Servlet源码。

JSP现在用得很少了,因为现在的Web开发一般是前后端分离的,JSP是不分离的,前后端分离可以解耦合,也让专人做专事。

对于前端而言,追求的是页面,兼容,用户体验等。对于后端而言,追求的是高并发,高可用,高性能,存储,安全等。

MVC开发

JSP 使编写 HTML 变得容易了起来,并且也支持在其中写Java代码,但是如果在 JSP 中写一大堆业务逻辑,包括 SQL 啊之类的,感觉也不是很好,而 MVC 的模式就能很好的结合二者。

MVC是Web开发中比较常用的一种模式,MVC全称是Model-View-Controller,其中 V 是JSP,C 是 Servlet,而 M 是传递在 Controller 和 View 传递的对象,这样做的好处是可以同时用 Servlet 来处理复杂的业务逻辑,用 JSP 来编写 HTML。

整个MVC架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
                   ┌───────────────────────┐
┌────>│Controller: UserServlet│
│ └───────────────────────┘
│ │
┌───────┐ │ ┌─────┴─────┐
│Browser│────┘ │Model: User│
│ │<───┐ └─────┬─────┘
└───────┘ │ │
│ ▼
│ ┌───────────────────────┐
└─────│ View: user.jsp │
└───────────────────────┘

使用MVC模式的好处是,Controller专注于业务处理,它的处理结果就是Model。Model可以是一个JavaBean,也可以是一个包含多个对象的Map,Controller只负责把Model传递给View,View只负责把Model给“渲染”出来,这样,三者职责明确,且开发更简单,因为开发Controller时无需关注页面,开发View时无需关心如何创建Model。

上面例子中的 Controller是继承自 Servlet,Controller 还可以不继承自 Servlet,用一个接收所有请求的 Servlet 映射到 / ,然后传递给不同的 Controller 处理,Controller再进一步传递给 View。这样的MVC的架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   HTTP Request    ┌─────────────────┐
──────────────────>│DispatcherServlet│
└─────────────────┘

┌────────────┼────────────┐
▼ ▼ ▼
┌───────────┐┌───────────┐┌───────────┐
│Controller1││Controller2││Controller3│
└───────────┘└───────────┘└───────────┘
│ │ │
└────────────┼────────────┘

HTTP Response ┌────────────────────┐
<────────────────│render(ModelAndView)│
└────────────────────┘

具体的实现参考廖雪峰的官网网站。评论区的雪莉胡远超1982同学总结得不错:

  • 核心思想是根据提出的简化原来用servlet、jsp的写法,构造满足需求(比如简单使用注解表示路径)的简易MVC框架;
  • 构造以url为key,dispatch为value的map,其中dispatch是封装了对req、response操作的method;
  • 初始化init函数就是通过扫描controller包中的controller对象并逐一遍历对象中的带有GetMapping注解的方法,构造上述的map;
  • 当请求来时,对一个url通过map找到对应的method,从而通过反射调用此方法,完成对请求的处理;

JavaEE 的 Servlet 规范还提供了 Filter 组件和 Listener 组件。Filter 可以封装 Servlet 的一些共有逻辑,比如登陆检查,编码转换等等,多个Filter会组成一个链,但没有固定的顺序。通过 Listener 可以监听 Web应用程序 的生命周期,获取HttpSession等创建和销毁的事件。

Spring MVC

Spring是目前最为流行的 Java EE 框架,它提供了一系列的基础设施,并且很容易和其它开源框架集成。Spring最核心的概念是IoC和AOP。

IoC 是控制反转,类似工厂模式。

AOP 是面向切面编程,指拓展功能而不修改源代码,类似装饰器模式。

在我们实现 MVC 框架的时候,最为核心的是 DispatcherServlet,DispatcherServlet 接收到来自客户端的请求后,再将其进一步转发到不同的 Controller,Spring MVC 已经实现了这一部分,可以通过代码和 web.xml 中注册两种方式来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MyWebApplicationInitializer implements WebApplicationInitializer {

@Override
public void onStartup(ServletContext servletCxt) {

// 加载 Spring Web Application 的配置
AnnotationConfigWebApplicationContext ac = new AnnotationConfigWebApplicationContext();
ac.register(AppConfig.class);
ac.refresh();

// 创建并注册 DispatcherServlet
DispatcherServlet servlet = new DispatcherServlet(ac);
ServletRegistration.Dynamic registration = servletCxt.addServlet("app", servlet);
registration.setLoadOnStartup(1);
registration.addMapping("/app/*");
}
}

以下是在 web.xml 中注册和初始化 DispatcherServlet 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!DOCTYPE web-app PUBLIC
"-//Sun Microsystems, Inc.//DTD Web Application 2.3//EN"
"http://java.sun.com/dtd/web-app_2_3.dtd" >

<web-app>
<servlet>
<servlet-name>dispatcher</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>com.itranswarp.learnjava.AppConfig</param-value>
</init-param>
<load-on-startup>0</load-on-startup>
</servlet>

<servlet-mapping>
<servlet-name>dispatcher</servlet-name>
<!-- "/*" 表示将所有请求交由 DispatcherServlet 处理 -->
<url-pattern>/*</url-pattern>
</servlet-mapping>
</web-app>

初始化参数contextClass指定使用注解配置的AnnotationConfigWebApplicationContext,配置文件的位置参数contextConfigLocation指向AppConfig的完整类名,最后,把这个Servlet映射到/*,即处理所有URL。

上述配置可以看作一个样板配置,有了这个配置,Servlet容器会首先初始化Spring MVC的DispatcherServlet,在DispatcherServlet启动时,它根据配置AppConfig创建了一个类型是WebApplicationContext的IoC容器,完成所有Bean的初始化,并将容器绑到ServletContext上。

因为DispatcherServlet持有IoC容器,能从IoC容器中获取所有@Controller的Bean,因此,DispatcherServlet接收到所有HTTP请求后,根据Controller方法配置的路径,就可以正确地把请求转发到指定方法,并根据返回的ModelAndView决定如何渲染页面。

配置完 DispatcherServlet 之后,编写 Controller 就很容易了,Spring 可以用 @Controller 来进行注释。

1
2
3
4
5
6
7
8
9
10
@Controller
public class HelloController {
@RequestMapping(value = "hello")
public ModelAndView hello(){
ModelAndView modelAndView = new ModelAndView();
modelAndView.setViewName("/WEB-INF/view/hello.jsp");
modelAndView.addObject("msg", "helloworld");
return modelAndView;
}
}

使用Spring MVC时,整个Web应用程序按如下顺序启动:

  1. 启动Tomcat服务器;
  2. Tomcat读取web.xml并初始化DispatcherServlet;
  3. DispatcherServlet创建IoC容器并自动注册到ServletContext中。

启动后,浏览器发出的HTTP请求全部由DispatcherServlet接收,并根据配置转发到指定Controller的指定方法处理。

Spring Boot

Spring Boot 进一步简化了 Web 开发的过程,在写 Spring MVC 的时候需要进行的各种配置 Spring Boot 都已经做了,使开发人员使用管业务即可,如果有特殊需求,只需要通过修改配置或编写少量代码就能完成。

二者对比,知乎上这张图很形象:

img

Spring Boot只需要一个依赖项来启动和运行Web应用程序:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

当我们引入spring-boot-starter-web时,自动创建了:

  • ServletWebServerFactoryAutoConfiguration:自动创建一个嵌入式Web服务器,默认是Tomcat;
  • DispatcherServletAutoConfiguration:自动创建一个DispatcherServlet
  • HttpEncodingAutoConfiguration:自动创建一个CharacterEncodingFilter
  • WebMvcAutoConfiguration:自动创建若干与MVC相关的Bean。

JSP 的 View 部分也可以直接在application配置文件中配置几个属性来完成。

1
2
spring.mvc.view.prefix=/WEB-INF/jsp/
spring.mvc.view.suffix=.jsp

更多 Spring 和 Spring Boot 的对比,可以参考 Spring 官网和这个博客

Spring Boot给你使用20%的时间去解决80%的问题,剩下20%的问题需要你用200%的时间去弄懂。——by:沃·兹基硕德

值得思考的地方(拖稿)

上面内容是我们如何去实现一个 Web服务器,但这其中还有很多被忽视的地方,比如浏览器的渲染,HTTP协议,Cookie等部分。在真正实现一个线上业务的时候,这些都是需要考虑的。

HTTP协议的发展

回到一开始用多线程实现 Web服务器的地方~

IO模型

Tomcat是什么IO模型呢?


最后,感谢廖大,学到很多~