Skip to content

04 各司其职的Server:拆分响应模块与处理模块

你好,我是郭屹。今天我们继续手写MiniTomcat。

在上一节课,我们基于最早的最小可用HttpServer服务器进行了改造。主要包括对HTTP协议返回内容中的状态行与返回头进行封装,以及引入动态资源和Servlet的概念,对Web端返回内容进行了扩充,已经有点Servlet容器的雏形了。

但我也提到,当前我们自定义的Servlet接口是不满足Java Servlet规范的。因此这节课我们首先会讨论如何符合Servlet规范,在Java的规则下实现MiniTomcat。

其次,在当前的HttpServer中,HttpServer类承担了接收客户端请求、调用Servlet、响应客户端等多种功能,功能太多了,因此我们要将其进行功能拆分,使各个部分各司其职。

好,就让我们一起来动手实现。

项目结构

这节课我们计划采用Maven结构对项目的包依赖进行管理,省去了导入jar包的环节。但有一点我们始终坚持,就是引入最少的依赖包,一切功能尽可能用最原生的JDK来实现,以便于我们从头做起更深地理解原理。在这节课中,项目结构变化如下:

MiniTomcat
├─ src
│  ├─ main
│  │  ├─ java
│  │  │  ├─ server
│  │  │  │  ├─ HttpConnector.java
│  │  │  │  ├─ HttpProcessor.java
│  │  │  │  ├─ HttpServer.java
│  │  │  │  ├─ Request.java
│  │  │  │  ├─ Response.java
│  │  │  │  ├─ ServletProcessor.java
│  │  │  │  ├─ StatisResourceProcessor.java
│  │  ├─ resources
│  ├─ test
│  │  ├─ java
│  │  │  ├─ test
│  │  │  │  ├─ HelloServlet.java
│  │  ├─ resources
├─ webroot
│  ├─ test
│  │  ├─ HelloServlet.class
│  ├─ hello.txt
├─ pom.xml

对比上节课的目录,你会发现新增了HttpConnector.java和HttpProcessor.java,这正是用来拆分HttpServer两个类的,而我们自定义的Servlet就消失不见了。根据Servlet规范,取而代之的应该是javax.servlet.Servlet类。

接下来我们需要把javax.servlet.Servlet引入到代码之中,参考以下pom.xml:

<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/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <groupId>day3</groupId>
    <artifactId>day3</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <dependencies>
        <dependency>
            <groupId>javax.servlet</groupId>
            <artifactId>javax.servlet-api</artifactId>
            <version>4.0.1</version>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.4</version>
        </dependency>
    </dependencies>
</project>

适配Servlet规范

所谓符合规范,对编写程序来讲,就是遵守流程调用时序和使用规定的API接口及数据格式。对Servlet规范来讲, 第一个要遵守的就是必须实现 Servlet 接口。

在引入上文的servlet-api依赖后,我们可以把原来自己定义的Servlet接口删除,用javax.servlet.Servlet替换。我们先看看javax.servlet.Servlet的接口定义。

package javax.servlet;
import java.io.IOException;
public interface Servlet {
    void init(ServletConfig var1) throws ServletException;
    ServletConfig getServletConfig();
    void service(ServletRequest var1, ServletResponse var2) throws ServletException, IOException;
    String getServletInfo();
    void destroy();
}

在工程中替换后,原先的代码会立即报错,因为service方法传入的参数是ServletRequest和ServletResponse,而我们目前使用的是自定义的Request类和Response类。

因此,接下来分别让Request和Response实现ServletRequest与ServletResponse,实现如下:

public class Request implements ServletRequest{
    private InputStream input;
    private String uri;
    //以输入流作为Request的接收参数
    public Request(InputStream input) {
        this.input = input;
    }
    //简单的parser,假定从输入流中一次性获取全部字节,存放到2K缓存中
    public void parse() {
        StringBuffer request = new StringBuffer(2048);
        int i;
        byte[] buffer = new byte[2048];
        try {
            i = input.read(buffer);
        }
        catch (IOException e) {
            e.printStackTrace();
            i = -1;
        }
        for (int j=0; j<i; j++) {
            request.append((char) buffer[j]);
        }
        //从输入的字符串中解析URI
        uri = parseUri(request.toString());
    }
    //根据协议格式,以空格为界,截取中间的一段,即为URI
    private String parseUri(String requestString) {
        int index1, index2;
        index1 = requestString.indexOf(' ');
        if (index1 != -1) {
            index2 = requestString.indexOf(' ', index1 + 1);
            if (index2 > index1)
                return requestString.substring(index1 + 1, index2);
        }
        return null;
    }
    public String getUri() {
        return uri;
    }
    @Override
    public AsyncContext getAsyncContext() {
        return null;
    }
    @Override
    public Object getAttribute(String arg0) {
        return null;
    }
    @Override
    public Enumeration<String> getAttributeNames() {
        return null;
    }
    @Override
    public String getCharacterEncoding() {
        return null;
    }
    @Override
    public int getContentLength() {
        return 0;
    }
    @Override
    public long getContentLengthLong() {
        return 0;
    }
    @Override
    public String getContentType() {
        return null;
    }
    @Override
    public DispatcherType getDispatcherType() {
        return null;
    }
    @Override
    public ServletInputStream getInputStream() throws IOException {
        return null;
    }
    @Override
    public String getLocalAddr() {
        return null;
    }
    @Override
    public String getLocalName() {
        return null;
    }
    @Override
    public int getLocalPort() {
        return 0;
    }
    @Override
    public Locale getLocale() {
        return null;
    }
    @Override
    public Enumeration<Locale> getLocales() {
        return null;
    }
    @Override
    public String getParameter(String arg0) {
        return null;
    }
    @Override
    public Map<String, String[]> getParameterMap() {
        return null;
    }
    @Override
    public Enumeration<String> getParameterNames() {
        return null;
    }
    @Override
    public String[] getParameterValues(String arg0) {
        return null;
    }
    @Override
    public String getProtocol() {
        return null;
    }
    @Override
    public BufferedReader getReader() throws IOException {
        return null;
    }
    @Override
    public String getRealPath(String arg0) {
        return null;
    }
    @Override
    public String getRemoteAddr() {
        return null;
    }
    @Override
    public String getRemoteHost() {
        return null;
    }
    @Override
    public int getRemotePort() {
        return 0;
    }
    @Override
    public RequestDispatcher getRequestDispatcher(String arg0) {
        return null;
    }
    @Override
    public String getScheme() {
        return null;
    }
    @Override
    public String getServerName() {
        return null;
    }
    @Override
    public int getServerPort() {
        return 0;
    }
    @Override
    public ServletContext getServletContext() {
        return null;
    }
    @Override
    public boolean isAsyncStarted() {
        return false;
    }
    @Override
    public boolean isAsyncSupported() {
        return false;
    }
    @Override
    public boolean isSecure() {
        return false;
    }
    @Override
    public void removeAttribute(String arg0) {
    }
    @Override
    public void setAttribute(String arg0, Object arg1) {
    }
    @Override
    public void setCharacterEncoding(String arg0) throws UnsupportedEncodingException {
    }
    @Override
    public AsyncContext startAsync() throws IllegalStateException {
        return null;
    }
    @Override
    public AsyncContext startAsync(ServletRequest arg0, ServletResponse arg1) throws IllegalStateException {
        return null;
    }
}

从代码中可以看出,我们只是简单地实现了对URI的解析,别的方法都是留空的。Java的API考虑得很全面,在Request里面新增了许多接口实现方法,但是就基本功能来讲,只要很少的方法就可以了,我们暂且先把这些现在不用的方法放在一边不实现。

接下来我们看看Response类的改造。

package server;
import javax.servlet.ServletOutputStream;
import javax.servlet.ServletResponse;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.util.Locale;
public class Response implements ServletResponse{
    Request request;
    OutputStream output;
    PrintWriter writer;
    String contentType = null;
    long contentLength = -1;
    String charset = null;
    String characterEncoding = null;

    //以输出流作为接收参数
    public Response(OutputStream output) {
        this.output = output;
    }
    public void setRequest(Request request) {
        this.request = request;
    }
    public OutputStream getOutput() {
        return this.output;
    }
    @Override
    public void flushBuffer() throws IOException {
    }
    @Override
    public int getBufferSize() {
        return 0;
    }
    @Override
    public String getCharacterEncoding() {
        return this.characterEncoding;
    }
    @Override
    public String getContentType() {
        return null;
    }
    @Override
    public Locale getLocale() {
        return null;
    }
    @Override
    public ServletOutputStream getOutputStream() throws IOException {
        return null;
    }
    @Override
    public PrintWriter getWriter() throws IOException {
        writer = new PrintWriter(new OutputStreamWriter(output,getCharacterEncoding()), true);
        return writer;
    }
    @Override
    public boolean isCommitted() {
        return false;
    }
    @Override
    public void reset() {
    }
    @Override
    public void resetBuffer() {
    }
    @Override
    public void setBufferSize(int arg0) {
    }
    @Override
    public void setCharacterEncoding(String arg0) {
        this.characterEncoding = arg0;
    }
    @Override
    public void setContentLength(int arg0) {
    }
    @Override
    public void setContentLengthLong(long arg0) {
    }
    @Override
    public void setContentType(String arg0) {
    }
    @Override
    public void setLocale(Locale arg0) {
    }
}

同样的,这个API也提供了一大堆方法。Response类里也因为实现接口,新增了许多接口实现方法,在目前这个阶段,我们只需要关注 getWriter() 这一个方法。

public PrintWriter getWriter() throws IOException {
    writer = new PrintWriter(new OutputStreamWriter(output,getCharacterEncoding()), true);
    return writer;
}

看上述实现,在这之前我们用 byte[] 数组类型作为output的输出,这对业务程序员来说是不太便利的,因此我们现在支持往输出流里写入String字符串数据,于是就需要用到PrintWriter类。可以看到这里调用了 getCharacterEncoding() 方法,一般常用的是UTF-8,所以在调用 getWriter() 之前,一定要先调用 setCharacterEncoding() 设置字符集。

在PrintWriter构造函数中,我们目前设置了一个值为true。这个值的含义为autoflush,当为true时,println、printf等方法会自动刷新输出流的缓冲。

当提供了writer后,我们着手改造ServletProcessor,改造后如下所示:

public class ServletProcessor {
    //返回串的模板,实际返回时替换变量
    private static String OKMessage = "HTTP/1.1 ${StatusCode} ${StatusName}\r\n"+
            "Content-Type: ${ContentType}\r\n"+
            "Server: minit\r\n"+
            "Date: ${ZonedDateTime}\r\n"+
            "\r\n";
    public void process(Request request, Response response) {
        String uri = request.getUri(); //获取URI
        //按照简单规则确定servlet名,认为最后一个/符号后的就是servlet名
        String servletName = uri.substring(uri.lastIndexOf("/") + 1);
        URLClassLoader loader = null;
        PrintWriter writer = null;
        try {
            // create a URLClassLoader
            URL[] urls = new URL[1];
            URLStreamHandler streamHandler = null;
            //从全局变量HttpServer.WEB_ROOT中设置类的目录
            File classPath = new File(HttpServer.WEB_ROOT);
            String repository = (new URL("file", null, classPath.getCanonicalPath() + File.separator)).toString() ;
            urls[0] = new URL(null, repository, streamHandler);
            loader = new URLClassLoader(urls);
        }
        catch (IOException e) {
            System.out.println(e.toString() );
        }
        //获取PrintWriter
        try {
            response.setCharacterEncoding("UTF-8");
            writer = response.getWriter();
        } catch (IOException e1) {
            e1.printStackTrace();
        }
        //加载servlet
        Class<?> servletClass = null;
        try {
            servletClass = loader.loadClass(servletName);
        }
        catch (ClassNotFoundException e) {
            System.out.println(e.toString());
        }

        //生成返回头
        String head = composeResponseHead();
        writer.println(head);
        Servlet servlet = null;
        try {
            //调用servlet,由servlet写response体
            servlet = (Servlet) servletClass.newInstance();
            servlet.service(request, response);
        }
        catch (Exception e) {
            System.out.println(e.toString());
        }
        catch (Throwable e) {
            System.out.println(e.toString());
        }
    }
    //生成返回头,根据协议格式替换变量
    private String composeResponseHead() {
        Map<String,Object> valuesMap = new HashMap<>();
        valuesMap.put("StatusCode","200");
        valuesMap.put("StatusName","OK");
        valuesMap.put("ContentType","text/html;charset=UTF-8");
        valuesMap.put("ZonedDateTime", DateTimeFormatter.ISO_ZONED_DATE_TIME.format(ZonedDateTime.now()));
        StrSubstitutor sub = new StrSubstitutor(valuesMap);
        String responseHead = sub.replace(OKMessage);
        return responseHead;
    }
}

主要变化有 3 处。

  1. 使用PrintWriter接口替换了原来的OutputStream。
  2. 在加载Servlet之前设置characterEncoding为 UTF-8,再获取Writer。
  3. Writer中设置了autoflush,因此不再需要像原来一样手动设置output.flush。

最后则是调整用来测试的HelloServlet,实现Servlet接口,在输出之前设置characterEncoding。

public class HelloServlet implements Servlet{
    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        res.setCharacterEncoding("UTF-8");
        String doc = "<!DOCTYPE html> \n" +
                "<html>\n" +
                "<head><meta charset=\"utf-8\"><title>Test</title></head>\n"+
                "<body bgcolor=\"#f0f0f0\">\n" +
                "<h1 align=\"center\">" + "Hello World 你好" + "</h1>\n";
        res.getWriter().println(doc);
    }
    @Override
    public void destroy() {
    }
    @Override
    public ServletConfig getServletConfig() {
        return null;
    }
    @Override
    public String getServletInfo() {
        return null;
    }
    @Override
    public void init(ServletConfig arg0) throws ServletException {
    }
}

通过测试我们可以看到,中文正确地输出了。

HttpServer职能拆解

在服务器符合Servlet规范之后,我们再转向服务器本身,也就是HttpServer这个核心实现类。

目前我们已拥有基本的Servlet Container功能,具备接收客户端请求,调用Servlet以及响应客户端请求的能力。为了达到各司其职的目标,我们可以把它拆分成两个大块:Connecctor和Processor,分别负责处理接收、响应客户端请求以及调用Servlet。

实现HttpProcessor.java如下:

public class HttpProcessor {
    public HttpProcessor(){
    }
    public void process(Socket socket) {
        InputStream input = null;
        OutputStream output = null;
        try {
            input = socket.getInputStream();
            output = socket.getOutputStream();
            // create Request object and parse
            Request request = new Request(input);
            request.parse();
            // create Response object
            Response response = new Response(output);
            response.setRequest(request);

            // check if this is a request for a servlet or a static resource
            // a request for a servlet begins with "/servlet/"
            if (request.getUri().startsWith("/servlet/")) {
                ServletProcessor processor = new ServletProcessor();
                processor.process(request, response);
            }
            else {
                StaticResourceProcessor processor = new StaticResourceProcessor();
                processor.process(request, response);
            }
            // Close the socket
            //socket.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在HttpProcessor中,process方法具体实现和原本并没有差异,只是新增Socket参数传入。现在有了这个专门的机构来分工,调用Servlet或者是静态资源。

HttpConnector实现如下:

public class HttpConnector implements Runnable {
    public void run() {
        ServerSocket serverSocket = null;
        int port = 8080;
        try {
            serverSocket = new ServerSocket(port, 1, InetAddress.getByName("127.0.0.1"));
        } catch (IOException e) {
            e.printStackTrace();
            System.exit(1);
        }
        while (true) {
            Socket socket = null;
            try {
                socket = serverSocket.accept();
                HttpProcessor processor = new HttpProcessor();
                processor.process(socket);
                // Close the socket
                socket.close();
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
    public void start() {
        Thread thread = new Thread(this);
        thread.start();
    }
}

需要注意的是HttpConnector,它实现了Runnable接口,把它看作一个线程,支持并发处理,提高整个服务器的吞吐量。而Socket的关闭,最后也统一交给Connector处理。

这样整个服务器基本的工作流程就是:由Connector接收连接,来了一个Socket之后,就转手交给Processor进行处理,处理完之后再返回给Connector来关闭。

最后调整HttpServer类,当前这个类的实现非常简单,只用于启动Connector这个线程,来等待客户端的请求连接。

public class HttpServer {
    public static final String WEB_ROOT =
            System.getProperty("user.dir") + File.separator + "webroot";
    public static void main(String[] args) {
        HttpConnector connector = new HttpConnector();
        connector.start();
    }
}

测试

src/test/java/test 目录下,修改HelloServlet。

package test;
import java.io.IOException;
import javax.servlet.Servlet;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
public class HelloServlet implements Servlet{
    @Override
    public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
        res.setCharacterEncoding("UTF-8");
        String doc = "<!DOCTYPE html> \n" +
                "<html>\n" +
                "<head><meta charset=\"utf-8\"><title>Test</title></head>\n"+
                "<body bgcolor=\"#f0f0f0\">\n" +
                "<h1 align=\"center\">" + "Hello World 你好" + "</h1>\n";
        res.getWriter().println(doc);
    }
    @Override
    public void destroy() {
    }
    @Override
    public ServletConfig getServletConfig() {
        return null;
    }
    @Override
    public String getServletInfo() {
        return null;
    }
    @Override
    public void init(ServletConfig arg0) throws ServletException {
    }
}

实现的Servlet是javax.servlet.Servlet,新增characterEncoding设置,最后用Writer输出自定义的HTML文本。

在准备工作进行完毕之后,我们运行HttpServer服务器,键入 http://localhost:8080/hello.txt 后,可以发现hello.txt里的所有文本内容,都作为返回体展示在浏览器页面上了。再输入 http://localhost:8080/servlet/test.HelloServlet 后,就可以看到浏览器显示:Hello World 你好。这也是我们在HelloServlet里定义的返回资源内容。

这说明整体功能改造成功。

小结

这节课我们按照Servlet规范,对Request、Response类以及测试用的HelloServlet进行了改造,主要的内容就是支持规范,所以从代码层面看增加了许多方法,但是目前我们都没有用到,实现的也仅仅是核心部分。

然后我们进一步将HttpServer功能进行拆分解耦,分成Connector和Processor,Connector负责实现接收网络连接和返回,Processor负责处理逻辑,即调用Servlet并返回。各个部分各司其职,并且考虑实现Runnable接口支持独立线程并发调用,为未来提高整体性能做准备。

本节课代码参见: https://gitee.com/yaleguo1/minit-learning-demo/tree/geek_chapter04

思考题

学完了这节课的内容,我们来思考一个问题:我们现在是在一个无限循环中每接收一个Socket连接就临时创建一个Processor来处理这个Socket,处理完毕之后再开始下一个循环,这个Server是串行工作模式,怎么提高这个Server的并发度?

欢迎你把你思考后的结果分享到评论区,也欢迎你把这节课的内容分享给其他朋友,我们下节课再见!