我们一般都会使用Tomcat服务器作为我们项目部署的容器,甚至SpringBoot项目的web-stater内嵌了tomcat服务器来方便我们项目的部署。
这一次我们就用Java从零开始编写一个类似tomcat的服务器吧。
首先,它基于Http协议,一个请求—响应模型。
浏览器从地址栏输入协议名、IP、端口号、请求的URI,然后该IP上对应端口号的那台服务器就会接收到请求,拿到请求信息,实际上就是一个字符串,类似于:
GET /login?name=tom HTTP/1.1
Host: localhost:8993
Connection: keep-alive
sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92"
sec-ch-ua-mobile: ?0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cookie: Webstorm-95c101bd=9363f558-8881-4ef4-a3e4-6c4451c8a381; Idea-7a4c39ec=5831f0ef-e48b-43a9-b8b9-190c7b9977fe
从这个字符串中,我们可以拿到请求的方式:GET,请求的URI:/login,以及请求所携带的参数:name=tom等信息。
拿到这些必要信息之后,我们可以做一些逻辑上的处理,例如数据的增删查改,数据的运算,其他接口的调用等等,最终会得出一个结果,给服务器返回响应,可能是一个html页面,也可能是JSON字符串或其他二进制数据。
所以,这个服务器的底层,本质还是一个Java Socket模型。
ServerSocket serverSocket = new ServerSocket(serverPort);
然后,服务器启动,监听指定的端口号serverPort:
Socket client = serverSocket.accept();
这个accept()方法是一个阻塞方法,如果客户端没有请求过来,那么就会一直阻塞这一行代码,不会继续向下执行,它模拟的是一个端口号监听-返回客户端对象的效果,必须保证服务器的主线程每时每刻都在监听该端口号,因此,一旦获取到客户端对象client,就立马交给一个线程去处理,其他业务逻辑不用管,主线程必须继续监听该端口号,因此这行代码是放入while(true)代码块里执行的。
while (isRunning) {
try {
Socket client = serverSocket.accept();
tpe.execute(new Dispatcher(client));
} catch (IOException e) {
e.printStackTrace();
log.debug("客户端错误..." + e.getMessage());
}
}
其中tpe是我们的线程池对象。
然后,就是每一个线程怎么处理这个client对象了。
线程拿到client以后,首先会解析请求信息:
InputStream in = client.getInputStream();
byte[] data = new byte[1024 * 1024];
int len = 0;
len = in.read(data);
if (len>0){
this.requestInfo = new String(data, 0, len);
this.parseRequestInfo();
this.parseParameterStr();
}
拿到请求信息,封装到request对象中,而后做出业务逻辑的处理,而为了方便业务逻辑的编写,这才有了Servlet规范。
为了返回相应,需要利用这个client对象构建响应流:
client.getOutputStream();
该流被封装到response对象中,在servlet里可以直接使用该流给客户端返回数据。
而给浏览器发送响应,是必须遵守HTTP协议的,即发送到响应,要有响应头,响应体的信息。
响应头我们写了一个方法,而响应体要根据具体情况指定,如果不是一个静态资源请求,响应体一般都在servelt中给定。
//构建响应头信息
private void createHeadInfo(int code) {
headInfo.append("HTTP/1.1").append(BLANK);
headInfo.append(code).append(BLANK);
switch (code) {
case 200:
headInfo.append("OK").append(CRLF);
break;
case 204:
headInfo.append("No Content").append(CRLF);
break;
case 206:
headInfo.append("Partial Content").append(CRLF);
break;
case 301:
headInfo.append("Moved Permanently").append(CRLF);
break;
case 302:
headInfo.append("Found").append(CRLF);
break;
case 303:
headInfo.append("See Other").append(CRLF);
break;
case 304:
headInfo.append("Not Modified").append(CRLF);
break;
case 307:
headInfo.append("Temporary Redirect").append(CRLF);
break;
case 400:
headInfo.append("Bad Request").append(CRLF);
break;
case 401:
headInfo.append("Unauthorized").append(CRLF);
break;
case 403:
headInfo.append("Forbidden").append(CRLF);
break;
case 404:
headInfo.append("Not Found").append(CRLF);
break;
case 500:
headInfo.append("Internal Server Error").append(CRLF);
break;
case 503:
headInfo.append("Service Unavailable").append(CRLF);
break;
}
headInfo.append("Date: ").append(getGreenwichDate()).append(CRLF);
headInfo.append("Server: ").append(Server.serverName).append(CRLF);
headInfo.append("Content-type: ").append(contentType).append(SEMICOLON).append("charset=").append(charSetEncoding).append(CRLF);
headInfo.append("Content-Length: ").append(len).append(CRLF);
headInfo.append(CRLF);
}
这里注意一个细节就是给客户端返回的响应头中Content-Length:
的长度,其实就是响应体占用的字节数,所以说,只有等到响应体确定以后,最后才构建响应头。
好了,整体的思路就介绍到这里。
编写这个web服务器,它主要包含以下几个部分:
- Java Socket模型
- 构建request对象
- 构建response对象
- servelt规范
- xml文件解析
- Java的annotation解析
- properties配置文件解析
- 线程池处理
- 静态资源路径解析
- 返回数据对象转JSON字符串
该案例中的web服务器,可以完成以下工作:
- 响应get、post请求,并从中拿到参数
- servlet配置,url请求映射
- 使用注解方式方便地配置servlet
- 静态资源请求权限控制
- 支持返回JSON数据
- 支持灵活地配置服务器各项参数
可以这样说,这个web服务器足以胜任简单的web项目开发及部署。
尚需改进的地方:
- 虽然可以返回JSON字符串,但暂时不能解析请求发送来的json字符串
- 不支持jsp,由于jsp技术涉及jstl表达式,el表达式,在html页面中嵌入这些动态部分,较为繁琐和复杂
- 限于笔者水平有限,代码健壮性、灵活性,还不够高
开发过程中遇到的难题和需注意的点:
-
服务器处理完请求以后,需要关闭client对象,调用client.close()方法。否则,服务器无法处理下一次请求或者会对下一次请求产生影响,例如请求时间延长等等。
-
静态资源读取的缓存问题:服务器中读取配置文件,一般都是通过
Thread.currentThread().getContextClassLoader().getResourceAsStream("xxx.xml");
来读取的,该方法的缺点在于它有一定的缓存机制,无法获取到最新的文件,如果配置文件是放置于一个新创建的文件夹里,它就会因为读取不到最新配置文件而出现空指针,虽然我们的服务器处理了空指针,但是解决不了本质问题,在Idea中关闭项目,重新打开,就好了。很遗憾,该问题最终一直未得到妥善解决,因此新创建的资源文件,可能会无法访问到,该问题影响了注解包路径的读取,xml和properties配置文件的读取,静态资源的读取。 -
在构建request对象的时候,遇到了一个问题,该问题也是困扰了很久的问题,最终得到解决。由于浏览器客户端各自不同的实现,也会影响到服务器端。以Chrome为代表的Webkit内核的浏览器,在第一次访问某域名下的链接时,会同时发送两个请求,第一个请求被正常处理,紧接着它会发送第二个请求,我把第二个请求暂定为“占位请求”,该请求没有发送任何信息,从
client.getInputStream()
的输入流中,调用in.read(data)
方法读取请求信息时,由于读取不到任何信息,该方法会发生IO阻塞,然后,同一个浏览器再次发送请求,这时候,它仅仅发送过来一个请求,我把它暂时叫做请求三,这时候,上一个占位请求被处理,它的请求信息,就是这次的请求信息,而请求三,则变成一个“占位请求”,以此往复。这样的情况,在一个浏览器里,是无可厚非的。但是,如果另一个浏览器也发送一个请求,因上一个浏览器的“占位请求”未得到处理和释放,因此新的浏览器过来发送的请求,会一直得不到处理。解决该问题的办法就是:必须将解析请求信息,构建request对象的过程放入线程里去做,主线程主要负责客户端对象的获取以及给线程分配任务,千万不能让主线程卡在IO阻塞里面。 -
Java对象转JSON字符串,这也算是其中的难题之一
-
注解所在包的读取,传过来一个“com.bussiness.logic”这样的字符串,需要得到该包名在服务器中所在的路径,注意这个路径还不能是磁盘里的绝对路径,因为我们需要拿到该包以及其子包下所有的class对象,相对路径要从该包开始。首先,通过递归,拿到该包及其子包下所有的类的全限定名,然后就可以很方便的通过
Class.forName()
获取class对象了。这是一个难点。可以参考如下代码AnnotationHandler:
package com.server.web;
import com.server.web.annotation.WebServlet;
import com.server.web.entity.Entity;
import com.server.web.entity.Mapping;
import java.io.File;
import java.io.UnsupportedEncodingException;
import java.lang.annotation.Annotation;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.URL;
import java.util.*;
/**
* @author qiaoao
* @description:
* @date 2021/7/27 18:16
*/
public class AnnotationHandler {
private List<Class<?>> classList;
private List<Entity> entityList;
private List<Mapping> mappingList;
public AnnotationHandler(){
this.classList = new ArrayList<>();
this.entityList = new ArrayList<>();
this.mappingList = new ArrayList<>();
}
public void parsePackage(String packageStr) throws Exception {
if ("".equals(packageStr.trim()) || "com".equals(packageStr)){
throw new UnsupportedOperationException();
}
if (packageStr.contains(".")){
packageStr = packageStr.replaceAll("[.]","/");
}
URL resource = this.getClass().getClassLoader().getResource(packageStr);
if (resource == null){
throw new RuntimeException("包路径不存在:"+packageStr);
}
File file = new File(resource.getFile());
int cutLen = file.getAbsolutePath().length() - packageStr.length();
parsePackage(file, cutLen);
parseAnnotation(classList);
}
private void parseAnnotation(List<Class<?>> classList) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, UnsupportedEncodingException {
for (Class<?> c : classList) {
Annotation annotation = c.getAnnotation(WebServlet.class);
if (annotation != null){
Class<? extends Annotation> aClass = annotation.getClass();
Method valueMethod = aClass.getMethod("value");
Method nameMethod = aClass.getMethod("name");
Method urlPatternsMethod = aClass.getMethod("urlPatterns");
String[] values = (String[])valueMethod.invoke(annotation);
String name = (String)nameMethod.invoke(annotation);
String[] urlPatterns = (String[])urlPatternsMethod.invoke(annotation);
if (name.length() == 0){
name = c.getName();
}
if (values.length > 0 && urlPatterns.length > 0) {
throw new UnsupportedEncodingException("value and urlPatterns cannot be valid at the same time");
}
Set<String> urlSet = new HashSet<>();
if (values.length > 0){
urlSet.addAll(Arrays.asList(values));
} else if (urlPatterns.length > 0){
urlSet.addAll(Arrays.asList(urlPatterns));
}
entityList.add(new Entity(name,c.getName()));
mappingList.add(new Mapping(name,urlSet));
}
}
}
private void parsePackage(File file,int cutLen){
File[] files = file.listFiles();
if (files!=null && files.length!=0){
for (File f:files){
parsePackage(f.getAbsoluteFile(),cutLen);
}
} else {
String classStr = file.getAbsolutePath().substring(cutLen);
if (classStr.contains(".class")){
classStr = classStr.replace(".class","").replaceAll("\\\\",".");
try {
Class<?> clazz = Class.forName(classStr);
classList.add(clazz);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
}
public List<Entity> getEntityList() {
return entityList;
}
public List<Mapping> getMappingList() {
return mappingList;
}
}
另外的补充说明:
该服务器需要三个配置文件,这里给出配置文件的模板及样例。
log4j.properties(必须的)他就是log4j的配置文件,不是我们自定义的,样例省略
web.xml(必须的)
<?xml version="1.0" encoding="UTF-8"?>
<web-app>
<servlet>
<servlet-name>login</servlet-name>
<servlet-class>com.business.logic.LoginServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>reg</servlet-name>
<servlet-class>com.business.logic.RegisterServlet</servlet-class>
</servlet>
<servlet>
<servlet-name>other</servlet-name>
<servlet-class>com.business.logic.OtherServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>login</servlet-name>
<url-pattern>/login</url-pattern>
<url-pattern>/g</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>reg</servlet-name>
<url-pattern>/reg</url-pattern>
</servlet-mapping>
<servlet-mapping>
<servlet-name>other</servlet-name>
<url-pattern>/o</url-pattern>
</servlet-mapping>
<resources>
<resource-directory>images</resource-directory>
<resource-pattern>/*</resource-pattern>
</resources>
<resources>
<resource-directory>jiguo</resource-directory>
<resource-pattern>/*</resource-pattern>
</resources>
</web-app>
server.properties(非必须,因为有默认配置)
# 服务器端口号
serverPort = 8993
# web.xml所在位置
webXmlLocation = web/web.xml
# 是否打印静态资源请求日志
printStaticRequestLog = false
# 是否开启注解支持
annotationSupport = true
# 注解所在的包
annotationPackage = com.business.logic
# 以下是线程池相关配置
corePoolSize = 10
maximumPoolSize = 15
keepAliveTime = 20
timeUnit = seconds
源码上传到了码云,需要自取:
https://gitee.com/luminescent-asphyxia/web-server.git