一个简单的Servlet容器的实现

2023年 7月 24日 53.1k 0

一个简单的servlet容器代码设计

Servlet算是Java Web开发请求链路调用栈中底层的一个技术,当客户端发起一个请求后,到达服务器内部,就会先进入Servlet(这里不讨论更底层的链路),SpringMVC的请求分发核心也是一个Servlet,名叫DispatcherServlet,一个请求首先会进入到这个Servlet,然后在通过SpringMVC的机制去分发到对应的Controller下。

但是再往上一层说,普通的开发人员可能不会关心Servlet是怎么被调用的,我们只要写一个@WebServlet注解在Servlet的类上,运行后,客户端的请求就会自动进入到相应的Servlet中,而做这些事的叫Servlet容器,Servlet容器一定是一个Web服务器,但Web服务器反过来可不一定是Servlet容器哦。

而了解一个Servlet容器的实现有助于更好的理解JavaWeb开发。

Github地址

项目最后的实现在Github上可以查看到

github.com/houxinlin/j…

容器的实现

在JavaWeb的开发世界,有很多都要遵守规范,JDBC也是,Servlet容器也是,Java很多不去做实现,只做接口,具体的实现留给各大厂商去做,而Servlet容器其中一个实现就是Tomcat。

Tomcat的实现还是很复杂的,这里也不做研究,我们只搞清楚一个小型的Servlet容器实现的步骤即可。

我们起一个容器名,叫JerryCat吧,他的实现功能只有一个,将请求交给对应的Servlet,并将其处理结果返回给客户端,因为这才是核心,而实现他的具体步骤如下。

  • 解压war文件
  • 收集Servlet信息
  • 启动web服务器
  • 请求映射 & 返回结果
  • 解压war文件

    当你在Tomcat的webapps目录下放入一个war文件,启动tomcat后,tomcat会自动把这个war文件解压了,后续所有的操作将会针对这个解压后的目录,而解压一个war文件很简单,代码如下。

     
    public static void unzipWar(String warFilePath, String outputFolder) throws IOException {
        byte[] buffer = new byte[1024];
        try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(Paths.get(warFilePath)))) {
            ZipEntry zipEntry;
            while ((zipEntry = zis.getNextEntry()) != null) {
                String entryName = zipEntry.getName();
                File newFile = new File(outputFolder + File.separator + entryName);
                if (zipEntry.isDirectory()) {
                    newFile.mkdirs();
                } else {
                    new File(newFile.getParent()).mkdirs();
                    try (FileOutputStream fos = new FileOutputStream(newFile)) {
                        int len;
                        while ((len = zis.read(buffer)) > 0) {
                            fos.write(buffer, 0, len);
                        }
                    }
                }
                zis.closeEntry();
            }
        }
    }
    

    收集Servlet信息

    这一步是一个核心,因为Servlet容器一定要知道一个war项目中所有的Servlet信息,也就是要知道开发人员定义的请求路径和具体Servlet的映射关系,当请求进来的时候,才能根据这个映射关系调用到对应的Servlet下。

    在Servlet 3.0规范以前,所有的映射关系需要在web.xml中去配置,比如下面这样,这个配置用来告诉容器将/hello的请求映射到com.example.HelloServlet下,容器只需要读取一个配置即可。

    
        HelloServlet
        com.example.HelloServlet
    
    
    
        HelloServlet
        /hello
    
    
    

    但是自从规范3.0开始,增加了@WebServlet等注解,如下,这也是告诉容器,这个类的请求路径是/hello

    @WebServlet("/hello")
    public class HelloServlet extends HttpServlet {}
    

    那么容器的实现就会增加负担,因为要遍历所有的class,找出标有@WebServlet的类,并做收集,那问题是怎么找到这些符合的类呢? 首先不能通过反射,因为有两个问题。

    第一个问题是类加载器的问题(这里假设你已经了解了类加载器的概念),因为容器的类加载器是不能加载war项目中的class的,即使能加载,你要通过Class.forName()去加载类时,在这个收集信息阶段,容器是不可能知道有那些类名称的,虽然可以通过在web.xml直接告诉容器,但说回来,尝试Class.forName()时会抛出ClassNotFoundException,而真正的容器实现都会自定义一个ClassLoader,专门去加载项目的class和资源。

    那么就算有了自定义的ClassLoader,可以加载到项目的class,那么Class.forName会触发static代码块,如果项目中的Servlet正好写了static代码快,则会调用,虽然最终这个代码块都会被调用,但不应该在这个时候,会出一些问题。

    而正确的做法是直接读取二进制class文件,从class文件规范中找到这个class是不是有@WebServlet注解,这是唯一的办法,Spring扫描注解的时候也是这样做的,而Tomcat也是这样,Tomcat解析class文件的类可以点击我查看。

    Tomcat是纯自己手撸出一个解析器,如果熟悉class文件格式后,还是比较容易的,所以这里我们依靠一个框架,比如用org.ow2.asm这个库,额外的知识:Spring也是靠第三方库来读取的。

    具体例子如下

    private void collectorServlet() {
        try {
            final Set classFileSet = new HashSet();
            Files.walkFileTree(Paths.get(this.webProjectPath, WEB_CLASSES_PATH), new SimpleFileVisitor() {
                @Override
                public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                    if (file.toString().endsWith(".class")) classFileSet.add(file.toString());
                    return super.visitFile(file, attrs);
                }
            });
            ClassNode classNode = new ClassNode();
            for (String classFile : classFileSet) {
                ClassReader classReader = new ClassReader(Files.newInputStream(Paths.get(classFile)));
                classReader.accept(classNode, ClassReader.EXPAND_FRAMES);
                List visibleAnnotations = classNode.visibleAnnotations;
                for (AnnotationNode visibleAnnotation : visibleAnnotations) {
                    if ("Ljavax/servlet/annotation/WebServlet;".equalsIgnoreCase(visibleAnnotation.desc)) {
                        Map annotationValues = ClassUtils.getAnnotationValues(visibleAnnotation.values);
                        Object o = loaderClass(classReader.getClassName());
                        servletMap.put(annotationValues.get("value").toString(), ((HttpServlet) o));
                    }
                }
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    
    
        private Object loaderClass(String name) {
            try {
                Class aClass = appClassloader.loadClass(name);
                return aClass.newInstance();
            } catch (ClassNotFoundException | InstantiationException | IllegalAccessException e) {
                throw new RuntimeException(e);
            }
        }
    

    主要就是遍历/WEB-INF/classes/目录,使用ClassReader类解析这个class文件,并判断是不是标有WebServlet注解,如果存在,则通过自定义的类加载器加载并实例化他,而这个类加载器主要作用就是根据给定的类名,从/WEB-INF/classes/加载类,如果给定的类不存在,则交给父类加载器。

    但向Tomcat都有一些公共的类区域,可以把所有项目所用到的共同库提取出来,放到一个目录下,另外war规范中,/WEB-INF/lib目录用来存放第三方的jar文件库,类加载器也需要考虑这个目录。

    那么这个类加载器加载路径依次如下:

  • /WEB-INF/classes/目录
  • /WEB-INF/lib目录
  • 公共区域
  • 父类加载器
  • 如果最后一个也加载不到,则抛出异常,拥有一个公共区域其实是很有必要的,通常来说我们都会依赖大量的第三方库,可能自己的代码和资源都不到10M,但是大量的第三方库可能占到上百M,部署传输起来可能不方便,正确的做法应该是把用到的第三方库一次性上传到公共区域,部署时只传自己的代码。

    并且类加载器还需要重写getResource、getResourceAsStream等这些方法用来在项目的类路径下查找资源。

    启动web服务器

    上面说到,Servlet容器也是一个Web服务器,只有启动一个Web服务器后,收到请求,才能传递给Servlet,并且,他还能处理静态资源,实现一个Web服务器重要的是解析HTTP报文,并且根据响应结果生成HTTP报文。

    这部分我们可以使用一个Java提供的现成库,如下。

    HttpServer httpServer = HttpServer.create(new InetSocketAddress(4040), 10);
    
  • HttpServer:是Java中用于创建HTTP服务器的类。它是Java SE 6引入的,用于支持简单的HTTP服务端功能。
  • HttpServer.create:用于创建一个新的HTTP服务器实例。
  • new InetSocketAddress(4040)InetSocketAddress表示IP地址和端口号的类。这里的4040是端口号,表示HTTP服务器将在本地计算机的4040端口上监听传入的HTTP请求。
  • 10:这是服务器的等待队列的最大长度。当HTTP服务器在处理传入的请求时,如果同时有更多请求到达,它们将被放入等待队列。这里的10表示等待队列的最大长度为10,即最多允许同时有10个请求在等待处理。
  • 请求映射 & 返回结果

    这里有一点比较麻烦,我们知道doGet和doPost的参数是HttpServletRequestHttpServletResponse,容器需要实现这两个接口,提供请求参数,这里我们偷个懒,使用mockito这个库来构造一个请求。

    下面代码中,createContext用来监听某个请求路径,当有请求过来时,HttpServer会把请求对象封装为HttpExchange,而我们做的事是把他转换为HttpServletRequest

    当调用service时,javax.servlet.http.HttpServlet会自动根据请求访问,调用doGet或者是doPost等。

    try {
        HttpServer httpServer = HttpServer.create(new InetSocketAddress(4040), 10);
        httpServer.createContext("/", httpExchange -> {
            Servlet servlet = servletMap.get(httpExchange.getRequestURI().toString());
            JerryCatHttpServletResponse httpServletResponse = new JerryCatHttpServletResponse(Mockito.mock(HttpServletResponse.class));
            HttpServletRequest httpServletRequest = createHttpServletRequest(httpExchange);
            if (servlet != null) {
                try {
                    servlet.service(httpServletRequest, httpServletResponse);
                    byte[] responseByte = httpServletResponse.getResponseByte();
                    httpExchange.sendResponseHeaders(200, responseByte.length);
                    httpExchange.getResponseBody().write(responseByte);
                    httpExchange.getResponseBody().flush();
                } catch (ServletException e) {
                    e.printStackTrace();
                }
            }
        });
        httpServer.start();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
    

    到这里就结束容器的任务了,只需要等待Servlet处理完成,将结果返回给客户端即可。

    但这里,请求映射显的有点简单,因为我们少了处理通配符的情况。

    其余规范

    其他特性我们不说,但属于Servlet规范的容器一定要实现,其余规范还有如ServletContainerInitializer、Filter等这里我们都没有实现,ServletContainerInitializer是一个很有用的东西,SpringBoot打包成war后,就依靠它去启动。

    Filter同样的做法,也是通过ClassReader读取,在调用service前一步,先调用Filter。

    结束

    这里只实现了一个容器的雏形中的核心,一个完整的容器,至少要做到提供完整的HttpServletRequest的实现,还有HttpServletResponse,这里只做演示,没有做太多处理,比如最重要的Cookie管理、Session管理,否则应用程序就无法实现用户登录状态维护。

    HttpServletRequest是继承ServletRequest的,他们定义的方法加起来共有70多个,需要一一去实现,才能给用户提供一个完整的请求信息供给,否则用户想拿一个请求头都拿不到,也没办法继续开发。

    有完整的信息提供后,就可以做额外的功能开发了,比如WebSocket,当请求过来时候,发现是一个WebSocket握手请求,那么相应的要做一个协议升级,转换为WebSocket协议。

    另外,一个容器进程是可以加载多个war项目的,就像tomcat,久而久之,支持的东西多了,就成了真正的容器。

    相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论