前言
此版本将会完善项目的一些细节,如:表单提交请求为post等...
一、支持post请求提交form表单
页面上要提交用户输入内容我们使用了form表单,而form表单的提交方式是通过method属性设置的,默认值为GET。还有一种提交方式为POST请求,使用这种方式提交表单时,数据会被包含在请求的消息正文中。
因此服务端如果想获取POST形式提交的数据,需要在HttpRequest中实现解析消息正文的工作。
通常页面上用户提交的数据包含敏感信息,比如密码。或者表单中包含附件信息时就要使用POST形式提交。
修改登录功能测试POST请求(注册功能同理,这里只是测试,所以以登录功能为例),实现步骤如下:
- 将登录页面login.html中form表单的提交方式修改为POST。
- 提交登录后会发现消息头中多出了Content-Type和Content-Length,表明当前请求包含消息正文,并说明了正文类型和长度。因此我们要在HttpRequest解析消息正文的方法中根据这两个头来解析内容。
- 第一步:在Request类中重构解析uri代码,并创建一个parseParameter方法来解析post请求:
/** * 进一步解析uri */ private void parseUri() { /* * uri会有两种不同的情况: * 1:含有参数 * 如果含有参数,要按照"?"将uri拆分,并将请求部分赋值给requestURI这个属性, * 将参数部分赋值给queryString这个属性,然后再将参数部分进一步拆分,将每一个 * 参数保存到parameters这个Map中,其中key是参数名,value是参数值。 * * 2:不含有参数 * 不含有参数则直接将uri赋值给requestURI即可,其余两个属性不需要做任何操作。 */ try { //将uri解码,将其中%XX内容还原为对应的文字。 uri = URLDecoder.decode(uri,"UTF-8"); } catch (UnsupportedEncodingException e) { e.printStackTrace(); } if (!uri.contains("?")) { requestURI = uri; } else { //先按照"?"拆分uri String[] data = uri.split("\\?"); requestURI = data[0]; if (data.length>1) { queryString = data[1]; //进一步拆分为一个参数 parseParameter(queryString); } } } private void parseParameter(String line) { String[] data = line.split("&"); //username="xxx"; for (String pairs : data) { //再将每个参数按照“=”拆分 String[] arr = pairs.split("="); if (arr.length>1) { parameters.put(arr[0], arr[1]); } else { parameters.put(arr[0], null); } } }
- 之前一直没有在解析消息正文中写代码是因为是get请求,信息会在浏览器地址栏上,但是post请求会将信息保存到消息正文中,所以我们要根据消息头里的Content-Type和Content-Length来解析正文内容,代码如下:
/** * 解析消息正文 */ private void parseContent() { System.out.println("Content:开始解析消息正文..."); //首先判断当前请求方式是否为post if ("post".equalsIgnoreCase(method)) { //获取Content-Length得知响应正文的长度 if (headers.containsKey("Content-Length")) { int len = Integer.parseInt(headers.get("Content-Length")); //读取响应正文数据 //1.先根据Content-Length创建字节数组 byte[] data = new byte[len]; try { //2.将正文一次性读取到数组中 in.read(data); //根据Content-Type判定正文类型 if (headers.containsKey("Content-Type")) { String contentType = headers.get("Content-Type"); System.out.println("正文类型:"+contentType); //根据正文类型不同做不同处理 if ("application/x-www-form-urlencoded".equals(contentType)) { /* * 如果正文类型是上述判定的值,则正文内容实际上就是原GET形式提交 * 表单时在地址栏中"?"右侧的内容,是一个字符串。 */ String line = new String(data,"ISO8859-1"); System.out.println("正文内容:"+line); line = URLDecoder.decode(line,"UTF-8"); System.out.println("转码后内容:"+line); //拆分参数 parseParameter(line); System.out.println("parameters:"+parameters); } //将来可以扩充对其他正文类型的支持 } } catch (IOException e) { e.printStackTrace(); } } } System.out.println("Content:消息正文解析完毕!"); }
- 第一步:在Request类中重构解析uri代码,并创建一个parseParameter方法来解析post请求:
二、使用线程池维护处理客户端交互的线程
之前的版本中,当一个客户端连接时,我们会启动一个线程来运行ClientHandler。每个ClientHandler完成的流程就是解析请求、处理请求、响应客户端,之后线程结束。
当多个客户端请求时会造成线程的频繁创建,由于线程的生命周期很短又会导致频繁的销毁,给系统带来额外的开销。
并且线程数量过多时,由于每个线程都要占用进程内存,那么线程越多占用资源越大,会导致可能出现的内存溢出问题。并且线程数量不加以控制,也会造成CPU的过度切换从而降低整体并发性能。
因此我们要使用线程池管理线程的数量并重用线程。
我们需要改良WebServer主类,代码如下:
1.创建线程池,先定义50条线程
2.利用线程池与客户端交互
三、优化Servlet
现在项目还存在一个问题:
每当我们添加一个新业务时,我们除了准备该业务需要的页面和处理该业务的Servlet类之外,还需要在ClientHandler添加一个分支判断:
当请求路径符合特定值时,就实例化对应的业务类并调用它的service方法。这样的做法不理想,我们最好将来能做到添加新业务时,不要每次都去修改ClientHandler,而是通过配置文件告知ClientHandler多了一个新的请求并对应哪个业务处理类,使得ClientHandler通过这种方式调用其service方法处理。
实现步骤:
1.首先在config目录下(之前放web.xml文件的目录)创建一个serlvets.xml文件,以后只要创建一个Servlet就需要在此xml文件中配置该Servlet。
2.要保证所有的Servlet类都有service方法,以免ClientHandler无法调用到该方法(我这里之前全部的Servlet的业务逻辑都写在service方法里面)。
3.定义一个超类:HttpServlet,并定义抽象方法service并要求所有的Servlet都继承它,这样所有的子类都一定包含这个方法了。
注:为什么定义成抽象方法?因为每个Servlet的业务是不一样的,因此service方法中的代码并不相同,所有抽象方法是保证了子类一定有这个方法,具体功能可以自行实现。
4.在com.webserver.core包下创建ServletContext类用于解析servlets.xml,用的到思想和之前解析web.xml大致一样,不过这里会涉及到Servlet的生命周期,在这里简单阐述一下:
- Serlvet的生命周期:
- 加载和实例化:程序启动后,Servlet容器通过类加载器找到Serlvet类并加载,加载成功后通过反射创建对应的Serlvet实例,并调用其无参构造,注:在编写Serlvet类时,根据业务需要,可以写有参构造,但是一定不能没有无参构造!
- 初始化:由于我们这里不涉及数据库连接,所以仅仅只有调用init方法,注:init方法只被调用一次,所以是静态的!
- 处理请求:调用service方法,根据不同的实现类调用不同的service方法,然后就是后续的一系列响应工作,注:在调用service方法之前,init方法必须被执行成功!
- 销毁:当一个Servlet容器检测到一个Servlet实例应该从服务中被移出的时候,此时容器会调用destroy方法释放资源,保存数据到持久层,该实例会被GC回收,需要时会再次创建一个新的Servlet实例。由于此项目是一个Tomcat服务器底层的代码,并不关联数据库,而且实现的功能较少,这里就不需要destroy方法。注:destroy方法也只被调用一次,所以也是静态的。
/**
* 保存服务端相关公用信息
* @Author JIANG
*/
public class ServerContext {
private static Map<String, HttpServlet> servletMapping = new HashMap<>();
static {
intiServletMapping();
}
/**
* 初始化所有请求与对应Servlet实例
*/
private static void intiServletMapping() {
/*
* 解析config/servlets.xml
* 将根标签下所有的servlet标签获取到,并将其中的
* 属性:path的值作为key
* 属性:className的值利用反射加载并实例化对应的Servlet作为value
* 保存到servletMapping中完成初始化。
*/
try {
SAXReader reader = new SAXReader();
Document doc = reader.read("config/servlets.xml");
Element root = doc.getRootElement();
List<Element> list = root.elements("servlet");
for (Element e : list) {
String key = e.attributeValue("path");
Class<?> cls = Class.forName( e.attributeValue("className"));
HttpServlet servlet = (HttpServlet) cls.newInstance();
servletMapping.put(key, servlet);
}
System.out.println("servletMapping:"+servletMapping);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 根据请求路径获取对应的Servlet
* @param path
* @return
*/
public static HttpServlet getServlet(String path) {
return servletMapping.get(path);
}
}
5.在ClientHandler中重构处理业务请求的代码,这样代码简化了许多:
至此,WebServer整个项目完结。
总结:
本项目的目的主要是解析Tomcat的底层是如何请求和响应,其实服务器的搭建比较简单,代码量并不大,主要是一些细节,比如说从最开始的处理空请求到如何解析post请求,再到uri的中文字符集转码,再到解析xml文件获取响应头类型,根据响应头类型来发送响应正文,用到的都是JAVASE一些比较基础的内容API,后面简单的实现了一些登录等功能,简单阐述了静态页面和动态页面的区别,最后利用servlet的思想将整个业务层重新优化了一下,最终呈现出的结果就是一个简易版的Tomcat,当然我们在工作中有框架支持,这些底层的东西也几乎不会写到,这里把这些思想分享给大家,有不足的地方请大家多多指出,我会尽可能的吸取大家的意见和建议。
最后,附一张整个项目的结构图,包含整体的思路:
如果大家对源码感兴趣可以去我的github下载:https://github.com/jiangyangsong