1000行代码手写HTTP服务器

来源:互联网 发布:教课程财务会计软件 编辑:程序博客网 时间:2024/06/05 09:04

1000行代码手写HTTP服务器(包括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

这里写图片描述
这个是项目的结构图,一个标准的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<Element> 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/{sessionScope.username} / applicationScope.username仿JSP{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课程。
这里写图片描述

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。
共勉。

原创粉丝点击