基于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行的代码量。
这个是项目的结构图,一个标准的maven构建的JavaWeb工程。src/main/java下面放的是源代码,src/main/resources下面放的是配置文件,src/main/webapp是下面放的是web相关的静态资源。
最最重要的当然是服务器主体,放在包的根目录下。
主要是使用了:
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方法对请求进行分别处理。
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();
}
}
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);
}
请求头和请求体的解析过程暂略,完全可以按照请求报文结构进行编码。
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: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);
}
}
设置一个根异常: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();
}
}
}
对于所有的静态资源,交由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();
}
}
}
服务器启动时会解析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。
这里说的模板引擎是很简单的字符串替换,比如 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就非常尴尬了…)。
这里顺便复习一下JavaWeb中老生常谈转发&重定向。转发是服务器内部发生的行为,对浏览器完全透明,一般是Servlet从Service层获取数据后,将数据存储到request/session/application域中,然后forward到某个页面,模板引擎将域中的数据填充到页面中,然后返回给浏览器;而redirect是客户端sensitive的,浏览器中的地址栏的URL会发生改变,通常是用于页面跳转。
我也是尽量模仿JavaEE API的转发和重定向,API非常类似。
在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是基于模板引擎的,会将页面中的占位符替换为域中的数据。
重定向的代码在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的,Cookie是浏览器提供的。一言以蔽之,Session是服务器创建,服务器保存,Cookie是服务器创建,客户端保存。第一次访问时的response会带上一个名为JSESSIONID的Cookie,每个JSESSIONID对应着一个session。以后浏览器的每次访问该网站的请求都会带上这个JSESSIONID,服务器通过这个Id来唯一标识一次会话,取出对应的session域,实现会话的维持。
关于Session&Cookie的实现,涉及Request和ServletContext类。
当用户(业务程序员)要求使用session时,如果当前请求已经有对应的session,那么直接返回;否则会创建一个session,并在响应头中加入一个Set-Cookie,值是JSESSIONID(通常是一个随机不重复的字符串,我这里使用的是UUID)。
代码如下:
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;
}
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);
}
这可能是第一次用Java造轮子,以后可能会更多地沉浸在造轮子的快乐中(怕不是个傻子)…比如Spring和SpringMVC。
最近心态比较浮躁,可能是因为面试屡屡受挫吧,暑期这么久又没有项目能做。了解了一下大公司的面试,往往会考察项目情况。如果没有机会做真实项目的话,自己造轮子也是有一定价值的,如果能真的会被人使用的话,也算是对开源事业做了点贡献。
Github地址:
https://github.com/songxinjianqwe/HTTPServer
如果有人喜欢,想自己也花点时间手写一个的话(只有1000行),欢迎fork和star。
共勉。