coding……
但行好事 莫问前程

自定义实现一个mini版Tomcat

通过之前的介绍,我们大致了解了Tomcat的基础功能,即为Servlet生效提供环境支持。那么Tomcat肯定需要提供以下基础功能:

  • 提供Socket服务:实现对某些端口的监听,从而实现请求到来时,Tomcat可以感知到。同时该Socket服务也需要支持HTTP协议。
  • 封装请求和响应:通过之前的介绍,我们知道在我们开发Servlet时,Web容器已经将Context、Request、Response替我们封装好了,可以直接使用。Tomcat作为一款Servlet容器,肯定要支持这项功能。
  • 请求分发:一个Tomcat可以提供多个Web服务,那么Tomcat需要支持将不同的url分发到不同的应用,调用不同的Servlet。

本篇文章我们就来自己实现一个简单的,具有上述基础功能的Servlet容器——MyTomcat。大致的通信过程如下图所示:

1. 基础概念

因为我们自定义处理的web服务器需要处理Http请求,所以必须支持Http协议。这里的支持其实简单的讲就是,能够正确识别客户端浏览器的请求内容,以及按照HTTP协议规定向客户端返回正确格式的返回内容。关于网络相关的基础概念,我们在之前的文章golang socket编程理解Https中都已经介绍过。这里我们再简单讲一下HTTP协议。

1.1 HTTP协议

Internet的基本协议是TCP/IP协议(传输控制协议和网际协议),目前广泛使用的 FTP、HTTP(超文本传输协议)、Archie Gopher都是建立在TCP/IP上面的应用层协议,不同的协议对应不同的应用。而HTTP协议是Web应用所使用的主要协议

  HTTP协议是基于请求响应模式的。客户端向服务器发送一个请求,请求头包含请求的方法、 URI、协议版本、以及包含请求修饰符、客户端信息和内容的类似MIME的消息结果。服务器则以一个状态行作为响应,相应的内容包括消息协议的版本、成功或者错误编码加上包含服务器信息、实体元信息以及可能的实体内容

  HTTP是无状态协议,依赖于瞬间或者近乎瞬间的请求处理。请求信息被立即发送,理想的情况是没有延时的进行处理,不过,延时还是客观存在的。HTTP有一种内置的机制,在消息的传递时间上由一定的灵活性:超时机制。一个超时就是客户端等待请求消息的返回信息的最长时间。

1.1.1 HTTP请求报文示例

1.1.2 HTTP响应报文示例

1.2 Socket

从我们之前学习的一些概念,可以知道TCP要保证可靠传输,要通过三次握手建立连接,传输数据时,要有滑动窗口、累积确认、分组缓存、流量控制等约束,断开连接时要通过四次挥手断开连接,是一个非常麻烦的协议。如果有个需求,使用TCP协议编程,设计一个客户端和服务端的通信系统,代价将是特别大的。这时候我们肯定想到一个概念——抽象。因为TCP协议非常复杂,不能要求每个程序员都去实现建立连接的三次握手、累计确认、分组缓存,这些应该属于操作系统内核部分,没必要重复开发。但是对于程序员来讲,操作系统需要抽象出一个概念,让上层应用可以使用抽象概念去编程,而这个抽象的概念就是Socket(Socket是操作系统抽象出来出我们更方便使用TCP协议的)。

1.3 为什么要使用应用层HTTP协议

通过上面的介绍,我们知道,通过Socket协议,我们已经可以实现客户端\服务端使用TCP传输协议通信,那么为什么还要定义一些如FTP、HTTP的应用层协议?

其实,虽然通过Socket可以实现客户端\服务端通信,但是通信内容其实是“无意义”的。比如针对浏览器\服务端通信,我们可能有一些特殊的要求,比如超时时间、请求方法、响应码等,不仅仅是交换内容那么简单。所以这时候就需要更细致,满足使用场景的协议。这就是应用层协议的意义。

2. Servlet接口定义

既然要自定义实现Tomcat Web容器,那么必然要抽象出一个接口供程序员使用,就像Servlet一样。这里我们提供一个抽象类MyServlet,只要程序员实现了该抽象类,就可以通过我们的自定义Web容器对外提供服务。

public abstract class MyServlet {
    public void doGet(MyRequest request, MyResponse response) {
        // 这里其实应该是response返回405这种状态码,因为我们的MyResponse没有这个功能,所以这里直接抛异常了
        throw new RuntimeException("不支持的HTTP GET method");
    }

    public void doPost(MyRequest request, MyResponse response) {
        // 这里其实应该是response返回405这种状态码,因为我们的MyResponse没有这个功能,所以这里直接抛异常了
        throw new RuntimeException("不支持的HTTP POST method");
    }

    public void service(MyRequest request, MyResponse response) {
        String method = request.getMethod();
        if (method.equalsIgnoreCase("POST")) {
            doPost(request, response);
        } else if (method.equalsIgnoreCase("GET")) {
            doGet(request, response);
        }
    }
}

这里其实参考了HttpServlet的实现,关于HttpServlet,我们在之前的文章透过现象看本质——什么是servlet中已经介绍了。

2. Request\Response抽象

2.1 请求类抽象——MyRequest

用过Socket的都知道,Socket请求都是字节流。又因为我们的Web容器其实是处理Http请求的,所以字节流中的内容其实就是HTTP请求体。我们抽象的请求体对象,必须能够支持HTTP协议的解析

public class MyRequest {
    private String url;

    private String method;

    public MyRequest(InputStream inputStream) throws IOException {
        String httpRequest = getStrFromInputStream(inputStream);

        /**
         * GET /hello HTTP/1.1
         * Host: localhost:9999
         * Connection: keep-alive
         * Cache-Control: max-age=0
         * Upgrade-Insecure-Requests: 1
         * User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.125 Safari/537.36
         * Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng;q=0.8,application/signed-exchange;v=b3;q=0.9
         * Sec-Fetch-Site: cross-site
         */
        String httpHead = httpRequest.split("\n")[0];
        url = httpHead.split("\\s")[1];
        method = httpHead.split("\\s")[0];
    }

    public String getUrl() {
        return url;
    }

    public String getMethod() {
        return method;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public void setMethod(String method) {
        this.method = method;
    }

    private String getStrFromInputStream(InputStream inputStream) throws IOException {
        InputStreamReader r = new InputStreamReader(inputStream);
        BufferedReader br = new BufferedReader(r);
        return br.readLine();
    }
}

其实核心就是读取上述请求示例,关于读取InputStream的方法,这里我试过很多种,只有当通过InputStreamReader读取的时候,我的这个自定义的Tomcat容器才能正确返回浏览器内容,并在浏览器显式。其他方式比如通过ByteArrayInputStream读取,都是不行的,有兴趣的同学可以尝试一下。

2.2 响应内容抽象——MyResponse

public class MyResponse {
    private OutputStream outputStream;

    public MyResponse(OutputStream outputStream) {
        this.outputStream = outputStream;
    }

    public void write(String content) {
        String html = "http/1.1 200 ok\n" + "Content-type: text/html; charset=utf-8\n" + "\n\n" + "<!DOCTYPE html>\n" + "<html><body>"
                + content + "</body></html>";
        PrintWriter out = new PrintWriter(outputStream);
        out.println(html);
        out.flush();
        out.close();
    }
}

写响应时,其实就是按照HTTP响应体的要求,写响应。这里MyRequest和MyResponse其实就是我们自定义的Tomcat容器对HTTP协议的支持。

3. Servlet映射

做过Socket开发的同学都知道,为了让我们实现的Servlet生效,我们需要再web.xml配置文件中配置我们的Servlet映射。其实就是告诉Tomcat,什么样的请求应该转发到哪一个Servlet中,这里我们自定义的Web服务器实现就不那么复杂了,直接写死映射。

public class MyServletMapping {
    private String servletName;

    private String url;

    private String clazz;

    public MyServletMapping(String servletName, String url, String clazz) {
        this.servletName = servletName;
        this.url = url;
        this.clazz = clazz;
    }

    public String getServletName() {
        return servletName;
    }

    public void setServletName(String servletName) {
        this.servletName = servletName;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getClazz() {
        return clazz;
    }

    public void setClazz(String clazz) {
        this.clazz = clazz;
    }
}
public class MyServletMappingConfig {
    public static List<MyServletMapping> myServletMappingList = new ArrayList<>();

    static {
        /**
         * 这个映射关系,Tomcat应该是读取web.xml配置获取的,这里为了方便,我们直接写死了
         */
        myServletMappingList.add(new MyServletMapping("helloWorld", "/hello", "com.zhuoli.service.test.HelloWorldServlet"));
    }
}

4. Socket实现Web容器

这其实就是整个自定义实现的Tomcat容器的核心——Socket实现Web容器。简单的讲,这里我们需要实现最开始讲的Web容器需要提供的三项功能:

  • 提供Socket服务,实现端口监听
  • 封装请求和响应
  • 请求分发
public class MyTomcat {
    private int port;

    private Map<String, String> url2ServletMap = new HashMap<>();

    public MyTomcat(int port) {
        this.port = port;
    }

    public void start() {
        initServletMapping();

        try {
            // 1. 端口监听
            ServerSocket server = new ServerSocket(9999);
            System.out.println("MyTomcat start on port " + port);

            while (true) {
                Socket socket = server.accept();

                // 2. 封装Request、Response
                MyRequest request = new MyRequest(socket.getInputStream());
                MyResponse response = new MyResponse(socket.getOutputStream());

                // 3. 请求分发
                dispatch(request, response);
                socket.close();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    private void initServletMapping() {
        for (MyServletMapping myServletMapping : MyServletMappingConfig.myServletMappingList) {
            url2ServletMap.put(myServletMapping.getUrl(), myServletMapping.getClazz());
        }
    }

    private void dispatch(MyRequest request, MyResponse response) {
        String servletClazz = url2ServletMap.get(request.getUrl());

        try {
            Class<MyServlet> myServletClass = (Class<MyServlet>) Class.forName(servletClazz);
            MyServlet myServlet = myServletClass.newInstance();
            myServlet.service(request, response);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        new MyTomcat(9999).start();
    }
}

5. 测试

这里我们自定义实现一个HelloWorldServlet,并通过上述main方法启动MyTomcat Web容器。

public class HelloWorldServlet extends MyServlet {
    @Override
    public void doGet(MyRequest request, MyResponse response) {
        response.write("hello world");
    }
}

说明我们自定义的Tomcat容器生效了。虽然很简单,但是也可以大致了解到Tomcat的工作流程。此外,我们可以发现,既然自定义Tomcat的核心是通过Socket实现的,那么是不是可以通过NIO实现呢,异或是通过Netty实现。答案是肯定的,有兴趣的大佬,可以尝试使用NIO、Netty、设计模式等高级操作,丰富我们的自定义Tomcat容器。

参考链接:

1. 手写一个迷你版的 Tomcat

2. 自己动手模拟开发一个简单的Web服务器

3. TCP/IP、Http、Socket的区别

4. tomcat 源码为啥不采用netty 处理并发?

赞(0) 打赏
Zhuoli's Blog » 自定义实现一个mini版Tomcat
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址