1000行代码手写Web服务器(一)

1000行代码手写Web服务器(包括HTTP服务器和Servlet容器)

具备的功能(均为简化版的实现):

  • HTTP Protocol 实现了HTTP协议
  • Servlet
  • ServletContext
  • Request 封装HTTP请求报文
  • Response 封装HTTP响应报文
  • DispatcherServlet Servlet转发
  • Static Resources & File Download 静态资源的访问
  • Error Notification 错误页面显示
  • Get & Post & Put & Delete 支持各种HTTP方法
  • web.xml parse 解析web.xml
  • Forward 转发
  • Redirect 重定向
  • Simple TemplateEngine 简单的模板引擎
  • session&cookie 会话管理

使用技术

基于Java BIO、多线程、Socket网络编程、XML解析、log4j/slf4j日志
只引入了junit,lombok(简化POJO开发),slf4j,log4j,dom4j(解析xml),mime-util(用于判断文件类型)依赖,与web相关内容全部自己完成。

参考资料

尚学堂《Java300集》中的195~207集,视频资料放在网盘里,大家可以直接下载,或者到尚学堂的官网上下载亦可。

链接: http://pan.baidu.com/s/1qYNnoLI 密码: j7sd

我是在其基本功能的基础上添加了一部分功能,并且尽量和标准的JavaEE API类似。当然我没有读过Tomcat的源码,可能真实实现有一些差距,而且为了尽量少地引入不必要的依赖,没有使用Spring,面向接口编程做的还不够,很多的地方是直接使用实现类的,这和JavaEE API差距是很大的。
我做的基本只是JavaEE API的模拟,也在项目里写了一个用户的登录注销的Demo。在健壮性上可能有很多不足,毕竟只花了两天的时间,大概只有1000行的代码量。

具体实现

Overview

1000行代码手写Web服务器(一)_第1张图片
这个是项目的结构图,一个标准的maven构建的JavaWeb工程。src/main/java下面放的是源代码,src/main/resources下面放的是配置文件,src/main/webapp是下面放的是web相关的静态资源。

HTTPServer

最最重要的当然是服务器主体,放在包的根目录下。
主要是使用了:

  • 一个Main线程,在main方法中执行,如果在控制台输入EXIT,那么服务器退出
  • 一个Listener线程,监听客户端的连接事件,并将请求交给DispatcherServlet进行转发。
  • 一个线程池,由DispatcherServlet维护,将访问Servlet的请求作为任务提交到线程池中,每个请求都在一个线程中执行。
      while (!Thread.currentThread().isInterrupted()) {
               Socket client;
               try {
                   //TCP的短连接,请求处理完即关闭
                   client = server.accept();
                   log.info("client:{}", client);
                   dispatcherServlet.doDispatch(client);
               } catch (IOException e) {
                   e.printStackTrace();
               }
           }

在DispatcherServlet中(放在/servlet/base包下),doDispatch方法对请求进行分别处理。

  1. 首先解析HTTP请求,将其封装到Request中。
  2. 如果是静态资源,交给静态资源处理器返回。
  3. 如果是动态资源,交由某个Servlet执行。
        try {
            //解析请求
            request = new Request(client.getInputStream());
            response = new Response(client.getOutputStream());
            request.setServletContext(servletContext);
            //如果是静态资源,那么直接返回
            if (request.getMethod() == RequestMethod.GET && (request.getUrl().contains(".") || request.getUrl().equals("/"))) {
                log.info("静态资源:{}", request.getUrl());
                //首页
                if (request.getUrl().equals("/")) {
                    resourceHandler.handle("/index.html", response, client);
                } else {
                    //其他静态资源
                    //与html有关的全部放在views里
                    if (request.getUrl().endsWith(".html")) {
                        resourceHandler.handle("/views" + request.getUrl(), response, client);
                    } else {
                        //其他静态资源放在static里
                        resourceHandler.handle("/static" + request.getUrl(), response, client);
                    }
                }
            } else {
                //处理动态资源,交由某个Servlet执行
                //Servlet是单例多线程
                //Servlet在RequestHandler中执行
                pool.execute(new RequestHandler(client, request, response, servletContext.dispatch(request.getUrl()), exceptionHandler));
            }

每个请求都被封装到一个RequestHandler中,它实现了Runnable接口,持有request&response。具体转发过程见后面的ServletContext部分。
在其run方法中,调用Servlet的service方法,执行正式的业务代码。

        try {
            if (servlet == null) {
                throw new ServletNotFoundException(HTTPStatus.NOT_FOUND);
            }
            //为了让request能找得到response,以设置cookie
            request.setRequestHandler(this);
            servlet.service(request, response);
            response.write();
        } catch (ServletException e) {
            exceptionHandler.handle(e, response, client);
        } catch (Exception e) {
           //其他未知异常
            exceptionHandler.handle(new ServerErrorException(HTTPStatus.INTERNAL_SERVER_ERROR), response, client);
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

Request

HTTP请求封装过程:
首先是读取socket的inputstream,将请求报文全部读进来,然后进行URL解码,并按CRLF(\r\n)进行切割。
切割后,解析请求头&请求体。

this.attributes = new HashMap<>();
        log.info("开始读取Request");
        BufferedInputStream bin = new BufferedInputStream(in);
        byte[] buf = null;
        try {
            buf = new byte[bin.available()];
            int len = bin.read(buf);
            if (len <= 0) {
                throw new RequestInvalidException(HTTPStatus.BAD_REQUEST);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }

        String[] lines = null;
        try {
            //支持中文,对中文进行URL解码
            lines = URLDecoder.decode(new String(buf, CharsetProperties.UTF_8_CHARSET), CharsetProperties.UTF_8).split(CharConstant.CRLF);
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        log.info("Request读取完毕");
        log.info("{}", Arrays.toString(lines));
        try {
            parseHeaders(lines);
            if (headers.containsKey("Content-Length") && !headers.get("Content-Length").get(0).equals("0")) {
                parseBody(lines[lines.length - 1]);
            }
        } catch (Throwable e) {
            e.printStackTrace();
            throw new RequestParseException(HTTPStatus.BAD_REQUEST);
        }

请求头和请求体的解析过程暂略,完全可以按照请求报文结构进行编码。

Response

HTTP响应的封装过程:
主要是header(…)和body(…)方法,header是封装响应头,有一些响应头是共有的,直接写死了在代码里了。另外还提供addHeader和addCookie方法进行扩展。最后在write方法中将响应报文写回到outputstream。
构建响应体,一种是调用body(byte[]),适合静态资源;另一种是多次调用print/println,类似于JavaEE API的方式,可以多次调用。

    public void write() {
        //默认返回OK
        if(this.headerAppender.toString().length() == 0){
            header(HTTPStatus.OK);
        }

        //如果是多次使用print或println构建的响应体,而非一次性传入
        if(body == null){
            log.info("多次使用print或println构建的响应体");
            body(bodyAppender.toString().getBytes(CharsetProperties.UTF_8_CHARSET));
        }

        byte[] header = this.headerAppender.toString().getBytes(UTF_8_CHARSET);

        //生成响应报文
        byte[] response = new byte[header.length + body.length];
        System.arraycopy(header, 0, response, 0, header.length);
        System.arraycopy(body, 0, response, header.length, body.length);
        try {
            os.write(response);
            os.flush();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                os.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

Servlet

设置了一个根Servlet:HTTPServlet,所有Servlet均继承于此,根据需求覆盖doGet/doPost/doPut/doDelete方法。也可以直接覆盖service方法,自定义实现。

    public void service(Request request, Response response) throws ServletException, IOException {
        if (request.getMethod() == RequestMethod.GET) {
            doGet(request, response);
        } else if (request.getMethod() == RequestMethod.POST) {
            doPost(request, response);
        } else if (request.getMethod() == RequestMethod.PUT) {
            doPut(request, response);
        } else if (request.getMethod() == RequestMethod.DELETE) {
            doDelete(request, response);
        }
    }

Exception

设置一个根异常:ServletException(放在/exception/base),所有与web有关的异常均继承于此,并绑定一个对应的HTTP状态码。
RequestHandler在执行Servlet时,如果抛出了异常,那么会交给ExceptionHandler(放在/exception/handler)进行处理,它会将对应的错误页面写入到输出流。
注意一个异常RequestInvalidException,有时候会出现读取请求报文内容为空的现象,直接抛弃报文即可,在实际访问时没有看出有什么影响。

    public void handle(ServletException e, Response response, Socket client) {
        try {
            if (e instanceof RequestInvalidException) {
                log.info("请求无法读取,丢弃");
            } else {
                log.info("抛出异常:{}", e.getClass().getName());
                e.printStackTrace();
                response
                        .header(e.getStatus())
                        .body(
                                IOUtil.getBytesFromFile(
                                        String.format(ERROR_PAGE, String.valueOf(e.getStatus().getCode())))
                        )
                        .write();
                log.info("错误消息已写入输出流");
            }
        } catch (IOException e1) {
            e1.printStackTrace();
        } finally {
            try {
                client.close();
            } catch (IOException e1) {
                e1.printStackTrace();
            }
        }
    }

Resource

对于所有的静态资源,交由ResourceHandler处理。
它会取出对应的静态资源并写入输出流,如果文件未找到,那么会将请求再次交给ExceptionHandler处理。

    public void handle(String url, Response response, Socket client) {
        try {
            if (ResourceHandler.class.getResource(url) == null) {
                log.info("找不到该资源:{}",url);
                throw new ResourceNotFoundException();
            }
            response.header(HTTPStatus.OK, MimeTypeUtil.getTypes(url)).body(IOUtil.getBytesFromFile(url)).write();
            log.info("{}已写入输出流", url);
        } catch (IOException e) {
            e.printStackTrace();
            exceptionHandler.handle(new RequestParseException(), response, client);
        } catch (ServletException e) {
            exceptionHandler.handle(e, response, client);
        } finally {
            try {
                client.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

ServletContext&web.xml

服务器启动时会解析web.xml,模仿了JavaWeb开发中web.xml的写法,使用和标签,以实现URL和Servlet的映射。我们使用了dom4j这个库来解析xml文件(视频中使用的是JavaAPi实现的,但我感觉SAX方式解析比较麻烦,而DOM方式编码简洁很多)。
WebApplication这个类的static代码块会在项目启动时执行,创建了一个ServletContext(放在/servlet/context),构造时会读取web.xml文件并将数据封装到Map中。
this.servlet = new HashMap<>();
this.mapping = new HashMap<>();
this.attributes = new ConcurrentHashMap<>();
this.sessions = new ConcurrentHashMap<>();
Document doc = XMLUtil.getDocument(ServletContext.class.getResource(“/WEB-INF/web.xml”).getFile());
Element root = doc.getRootElement();
List servlets = root.elements(“servlet”);
for (Element servlet : servlets) {
String key = servlet.element(“servlet-name”).getText();
String value = servlet.element(“servlet-class”).getText();
HTTPServlet httpServlet = null;
try {
httpServlet = (HTTPServlet) Class.forName(value).newInstance();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
this.servlet.put(key, httpServlet);
}

    List mappings = root.elements("servlet-mapping");
    for (Element mapping : mappings) {
        String key = mapping.element("url-pattern").getText();
        String value = mapping.element("servlet-name").getText();
        this.mapping.put(key, value);
    }

这里使用了dom4j的API读取web.xml,并使用反射来创建Servlet实例。
注意attributes和session可能会产生并发修改,所以要使用ConcurrentHashMap,基于CAS实现并发修改的线程安全。

    //由URL得到对应的Servlet类
    public HTTPServlet dispatch(String url) {
        return servlet.get(mapping.get(url));
    }

这段代码实现了由URL得到Servlet实例的逻辑。
此外ServletContext还负责维护域对象和session。稍后再解释Session。

Template

这里说的模板引擎是很简单的字符串替换,比如 requestScope.username/ r e q u e s t S c o p e . u s e r n a m e / {sessionScope.username} / applicationScope.username仿JSP a p p l i c a t i o n S c o p e . u s e r n a m e , 模 仿 一 下 J S P 。 不 能 从 取 一 个 对 象 里 的 某 属 性 ( 比 如 {requestScope.user.username}),如果要实现的话,应该需要使用反射。
遇到这些${}占位符时,会在forward时自动从request/session/servletContext中寻找是否有对应的key,并进行字符串替换。

    public static String resolve(String content, Request request) throws TemplateResolveException {
        Matcher matcher = regex.matcher(content);
        StringBuffer sb = new StringBuffer();
        while (matcher.find()) {
            log.info("{}", matcher.group(1));
            String placeHolder = matcher.group(1);
            if (placeHolder.indexOf('.') == -1) {
                throw new TemplateResolveException();
            }
            ModelScope scope = ModelScope
                    .valueOf(
                            placeHolder.substring(0, placeHolder.indexOf('.'))
                                    .replace("Scope", "")
                                    .toUpperCase());
            String key = placeHolder.substring(placeHolder.indexOf('.') + 1);
            if (scope == null) {
                throw new TemplateResolveException();
            }
            Object value = null;
            switch (scope) {
                case REQUEST:
                    value = request.getAttribute(key);
                    break;
                case SESSION:
                    value = request.getSession().getAttribute(key);
                    break;
                case APPLICATION:
                    value = request.getServletContext().getAttribute(key);
                    break;
                default:
                    break;
            }
            log.info("value:{}",value);
            if (value == null) {
                matcher.appendReplacement(sb, "");
            } else {
                //把group(1)得到的数据,替换为value
                matcher.appendReplacement(sb, value.toString());
            }
        }
        return sb.toString();
    }

这里使用了一个正则表达式,分组并进行替换。如果找不到的话,替换为空串(替换成null就非常尴尬了…)。

Forward&Redirect

这里顺便复习一下JavaWeb中老生常谈转发&重定向。转发是服务器内部发生的行为,对浏览器完全透明,一般是Servlet从Service层获取数据后,将数据存储到request/session/application域中,然后forward到某个页面,模板引擎将域中的数据填充到页面中,然后返回给浏览器;而redirect是客户端sensitive的,浏览器中的地址栏的URL会发生改变,通常是用于页面跳转。
我也是尽量模仿JavaEE API的转发和重定向,API非常类似。

Forward

ApplicationRequestDispatcher(/request/dispatch/impl)中包含了转发的逻辑。

    @Override
    public void forward(Request request, Response response) throws ServletException, IOException {
        if (ResourceHandler.class.getResource(url) == null) {
            throw new ResourceNotFoundException();
        }
        String body = TemplateResolver.resolve(new String(IOUtil.getBytesFromFile(url), CharsetProperties.UTF_8_CHARSET),request);
        response.header(HTTPStatus.OK, MimeTypeUtil.getTypes(url)).body(body.getBytes(CharsetProperties.UTF_8_CHARSET));
    }

forward是基于模板引擎的,会将页面中的占位符替换为域中的数据。

Redirect

重定向的代码在Response中。

    public void sendRedirect(String url){
        log.info("重定向至{}",url);
        addHeader(new Header("Location",url));
        header(HTTPStatus.MOVED_TEMPORARILY);
        body(bodyAppender.toString().getBytes(CharsetProperties.UTF_8_CHARSET));
    }

是在响应头中加入一个Header,key是Location,value是地址。
注意!
虽然forward和redirect都是转向一个页面,但是页面的路径不一样,前者是相对于服务器的相对路径,而后者是相对于浏览器的绝对路径,需要包含Scheme,ServerName,Port。
这里祭出一张非常好的解释路径的图,来自传智播客的30天精通JavaWeb课程。
1000行代码手写Web服务器(一)_第2张图片

Session&Cookie

这又是老生常谈的话题,听课的时候感觉听懂了,实现的时候发现还有一些细节不太清楚。Session是基于Cookie的,Cookie是浏览器提供的。一言以蔽之,Session是服务器创建,服务器保存,Cookie是服务器创建,客户端保存。第一次访问时的response会带上一个名为JSESSIONID的Cookie,每个JSESSIONID对应着一个session。以后浏览器的每次访问该网站的请求都会带上这个JSESSIONID,服务器通过这个Id来唯一标识一次会话,取出对应的session域,实现会话的维持。
关于Session&Cookie的实现,涉及Request和ServletContext类。
当用户(业务程序员)要求使用session时,如果当前请求已经有对应的session,那么直接返回;否则会创建一个session,并在响应头中加入一个Set-Cookie,值是JSESSIONID(通常是一个随机不重复的字符串,我这里使用的是UUID)。
代码如下:

  • Request:
    public HTTPSession getSession() {
        if (session != null) {
            return session;
        }
        for (Cookie cookie : cookies) {
            if (cookie.getKey().equals("JSESSIONID")) {
                log.info("servletContext:{}",servletContext);
                HTTPSession currentSession = servletContext.getSession(cookie.getValue());
                if (currentSession != null) {
                    this.session = currentSession;
                    return session;
                }
            }
        }
        session = servletContext.createSession(requestHandler.getResponse());
        return session;
    }
  • ServletContext:
    public HTTPSession getSession(String JSESSIONID) {
        return sessions.get(JSESSIONID);
    }

    public HTTPSession createSession(Response response){
        HTTPSession session = new HTTPSession(UUIDUtil.uuid());
        sessions.put(session.getId(),session);
        response.addCookie(new Cookie("JSESSIONID",session.getId()));
        return session;
    }

域对象

众所周知,JavaWeb中有三大域对象:Request,Session,Application(或许还有pageContext,这里暂且不算)。我在Request、Session和ServletContext中都设置了一个名为attributes的Map,用于保存请求处理过程中的数据。
大概结构都是这样子:

    public void setAttribute(String key, Object value) {
        attributes.put(key, value);
    }

    public Object getAttribute(String key) {
        return attributes.get(key);
    }

未来希望添加/改进的地方:

  • NIO实现多路复用
  • 手写WebSocket服务器,实现HTTP长连接
  • Filter
  • Listener
  • 手写Spring的IOC容器以及AOP,更多的面向接口编程

总结

这可能是第一次用Java造轮子,以后可能会更多地沉浸在造轮子的快乐中(怕不是个傻子)…比如Spring和SpringMVC。
最近心态比较浮躁,可能是因为面试屡屡受挫吧,暑期这么久又没有项目能做。了解了一下大公司的面试,往往会考察项目情况。如果没有机会做真实项目的话,自己造轮子也是有一定价值的,如果能真的会被人使用的话,也算是对开源事业做了点贡献。
Github地址:

https://github.com/songxinjianqwe/HTTPServer

如果有人喜欢,想自己也花点时间手写一个的话(只有1000行),欢迎fork和star。
共勉。

你可能感兴趣的:(后端)