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方法对请求进行分别处理。
- 首先解析HTTP请求,将其封装到Request中。
- 如果是静态资源,交给静态资源处理器返回。
- 如果是动态资源,交由某个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
这里说的模板引擎是很简单的字符串替换,比如
遇到这些${}占位符时,会在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。
共勉。
- 1000行代码手写HTTP服务器
- http服务器返回代码
- HTTP服务器状态代码定义
- HTTP服务器状态代码定义
- HTTP服务器状态代码定义
- HTTP服务器状态代码定义
- JAVA-手写服务器
- java 手写服务器
- 手写实现Tomcat服务器
- 手写简易WEB服务器
- 手写Ajax核心代码
- Ajax手写代码应知应会
- 手写代码--->模仿记事本
- spring 原理手写代码
- JS手写AJAX代码
- 旧代码 - 手写堆
- 手写代码 (总结)
- iOS 手写代码UICollectionView
- STP个人理解
- HttpsURLConnection发送get型式参数
- Android App整体架构设计的思考
- 题目1448:Legal or Not
- 关于显示隐藏的小技巧
- 1000行代码手写HTTP服务器
- 解析并且存入数据库
- 使用js进行时间戳与日期的相互转化
- truncate和delete的区别
- MyApplication缓存1
- JavaScript学习笔记(续)
- HDU --- 3861 The King’s Problem 【强联通缩点 + 最小路径覆盖】
- 对Linux中inode的感悟
- Android (拍照功能)