今夜我们一起学习Java手写Web服务器

我们一般都会使用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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值