上一篇博客介绍了一些和具体功能无关的类,还记得那个使用switch来处理的路由吗?每一个case都会对应一个具体的类,用来返回具体的响应。下面会逐一介绍每一个类的功能。
ServDragon类
因为每一个处理具体功能的类,都会有request
、response
属性和doGet
方法,所以定义一个抽象基类 ServDragon,这样每个类直接继承它会更加方便了,可以简化一些代码,并且结构更有层次。
package com.dragon.server;
public abstract class ServDragon {
protected Request request;
protected Response response;
public ServDragon(Request request, Response response) {
this.request = request;
this.response = response;
}
public abstract void doGet();
}
Index类
处理用户请求:/ 和 /index.mmp
package com.dragon.server;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.Charset;
public class Index extends ServDragon {
public Index(Request request, Response response) {
super(request, response);
}
public void doGet() {
StringBuilder msg = new StringBuilder();
msg.append("<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>HomePage 龙先生</title></head><body>");
for (String filename : DragonServer.picMap.keySet()) {
msg.append("<img src=\"/pictures/").append(filename).append("\" width=\"300\"/>"); //这里使用相对路径,不使用绝对路径,方便调试部署(虽然不需要部署)
}
msg.append("</body></html>");
byte[] content = msg.toString().getBytes(Charset.forName("UTF-8"));
byte[] header = response.getHeader("text/html", content.length, 200, "OK");
OutputStream out = new BufferedOutputStream(response.getOutputStream());
try {
out.write(header);
out.write(content);
out.flush(); //刷新流
} catch (IOException e) {
e.printStackTrace();
} finally {
response.close();
}
}
}
说明: 这里上面是一个拼接的html片段,可以看出来是很简单的一个html。我对应前端其实不太了解,没有系统学习过。
它的结构大致如下,这里 img 标签上面的算第一个部分,img 标签算第二部分,下面是第三部分。
然后使用循环生成多个 img 图片,把图片的地址填充进去。最后将这三部分拼接起来,就组成了向用户返回的请求报文体(html页面)。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>HomePage 龙先生</title>
</head>
<body>
<img src="/pictures/xxx.jpg"/>
</body>
</html>
疑问:为什么需要这样做?
答: 这是因为请求报文头需要获取到报文体的大小,也就是说头部包含了报文体的信息,而且头部必须先发送,所以如果不提前准备好报文体,我这里是无法发送报文的。因为报文体还没准备好,我怎么可能知道报文的大小呢!(注意:大小指的是字节大小!)这里也是有解决方式的,可以使用一种称为 chunked 传输的方式(分块传输),当时这个我也只是听过,并不是很了解。所以,我这里使用 Content-Length 的方式,是必须提前准备好报文体的。
注:实际生成的页面,这个东西是我们前面拼接出来的。如果具有JSP和Servlet的学习经验,应该会了解到在没有JSP这项技术之前,Java Web程序员就是通过这种方式来编写web应用的。并且,JSP本质上也是 Servlet 类,只不过编写的时候方便多了。
Favicon类
当使用浏览器访问某个web页面时,浏览器会自动发送一个请求,请求该网站的图标。(当然了应该也是会缓存的,而且通常只发送一次。)
package com.dragon.server;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class Favicon extends ServDragon {
public Favicon(Request request, Response response) {
super(request, response);
}
public void doGet() {
Path path = Paths.get("D:", "DragonFile", "ServerImg", "favicon.ico");
//响应体数据
File file = path.toFile();
byte[] header = null;
byte[] content = null;
try {
content = Files.readAllBytes(path); //使用 Files 工具类,一次性读取文件
String contentType = Files.probeContentType(file.toPath()); //获取文件的 MIME 类型
long length = file.length(); //获取文件字节长度
header = response.getHeader(contentType, length, 200, "OK"); // 填充响应头
} catch (IOException e) {
e.printStackTrace();
}
OutputStream out = new BufferedOutputStream(response.getOutputStream());
try {
out.write(header);
out.write(content);
out.flush(); //刷新输出流
} catch (IOException e) {
e.printStackTrace();
} finally {
response.close();
}
}
}
web图标:
我把这个和我的其它图片存放到一个文件夹,但是它不会直接显示在网站上,因为它会被程序启动时过滤掉。但是通过 /favicon.ico 是可以访问到的,它的效果很简单,就是下面这个小图标。
NotFound 类
这里增加一个对非法请求响应的类,还记得前面博客那个消灭404的程序吗?虽然没有什么作用,但是还是挺有趣的!这个类的功能很简单,如果它识别了要给非法的请求的话,就是把一个html响应给浏览器。(这里考虑的很不完善,例如我访问一个不存在的图片,它只会导致程序异常,并不会弹出来这个页面。我也就不改了吧!好吧,我还是加上了访问错误图片的请求,但是我需要明确指出这个程序仍然是有很多的问题的,不过拿来学习参考还是挺好的。)
package com.dragon.server;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.Charset;
public class NotFound extends ServDragon {
public NotFound(Request request, Response response) {
super(request, response);
}
public void doGet() {
String msg = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/>"
+ "<title>Not Found!</title></head><body><h1>Not Found!</h1></body></html>";
byte[] content = msg.getBytes(Charset.forName("UTF-8"));
byte[] header = response.getHeader("text/html;charset=utf-8", content.length, 404, "Not Found"); //这里最开始我使用了 text/plain 这是不对的
OutputStream out = new BufferedOutputStream(response.getOutputStream());
try {
out.write(header);
out.write(content);
out.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
}
UploadPage类
用于上传图片的html页面,这个页面只有一个form表单,用于提交图片使用。它和上面的 NotFound 类似,都是返回一个html页面,除了上传表单部分要指定 enctype=“multipart/form-data” 这个属性,也就没有什么了。
package com.dragon.server;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.Charset;
/**
* 上传图片的页面
* */
public class UploadPage extends ServDragon {
public UploadPage(Request request, Response response) {
super(request, response);
}
@Override
public void doGet() {
//上传页面
String msg = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>Upload 上传</title>"
+ "</head><body><h1>Upload Picture</h1><form action=\"/upload\" method=\"post\" "
+ "enctype=\"multipart/form-data\"><input type=\"file\" name=\"file\"/>"
+ "<input type=\"submit\" value=\"submit\"/></form></body></html>";
byte[] content = msg.getBytes(Charset.forName("UTF-8"));
byte[] header = response.getHeader("text/html;charset=utf-8", content.length, 200, "OK"); //指定编码,不然中文就乱码了
OutputStream out = new BufferedOutputStream(response.getOutputStream());
try {
out.write(header);
out.write(content);
out.flush(); //刷新输出流
} catch (IOException e) {
e.printStackTrace();
}
}
}
Pictures类
这个类就是响应用户请求的图片数据了,这样比通常的web开发更能感觉到底层吧。以往在 tomcat 上的图片上传、下载的代码,感觉的可以理解。但是不如这个更加底层,并且那个也是封装过的了。或者使用 Apache的 CmomonIO jar,对于开发者来说确实是很方便,但是对于初学者往往是不够友好的,因为不是太明白它的底层实现。
这里使用输出流来处理底层的实现细节,还是挺好理解的。读取图片的字节数组,然后输出给客户端。并且这里加了一个判断,如果文件不存在的话,就返回到 404 页面。
这里使用 Files
类的这个方法可以得到文件的MIME 类型,还有一个类的方法也可以做到,它是URLConnection
类。
Files.probeContentType(file.toPath());
URLConnection.guessContentTypeFromName(filename);
package com.dragon.server;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
public class Pictures extends ServDragon {
public Pictures(Request request, Response response) {
super(request, response);
}
public void doGet() {
String fileName = request.getParam("filename");
//判断是否存在该路径
Path path = Paths.get("D:", "DragonFile", "ServerImg", fileName);
//响应体数据
File file = path.toFile();
if (!file.exists()) { //如果文件不存在,返回404页面!
NotFound notFound = new NotFound(request, response);
notFound.doGet();
return ; //然后结束方法的执行。
}
byte[] header = null;
byte[] content = null;
try {
content = Files.readAllBytes(path); //使用 Files 工具类,一次性读取文件
String contentType = Files.probeContentType(file.toPath()); //获取文件的 MIME 类型 这里不能加 charset=utf-8 否则打开就变成下载了
long length = file.length(); //获取文件字节长度
header = response.getHeader(contentType, length, 200, "OK"); // 填充响应头
} catch (IOException e) {
e.printStackTrace();
}
OutputStream out = new BufferedOutputStream(response.getOutputStream());
try {
out.write(header);
out.write(content);
out.flush(); //刷新输出流
} catch (IOException e) {
e.printStackTrace();
} finally {
response.close();
}
}
}
PicUpload类
这里是最麻烦的一个类了,因为这里涉及到了解析报文,我前面的博客里面虽然已经解析了一个简单的响应报文,但是我没有想到这个上传文件的请求报文其实是很复杂的。因为是上传文件,所以使用了 multipart/form-data 的方式。
具体可以参考这篇博客:HTTP POST请求报文格式分析与Java实现文件上传
这里我的报文中只有一个图片,我需要接收到报文,并把图片存入我的服务目录。这其实不是一件好做的事情,我不是太清楚这个 boundary 的作用,似乎是两个 boundary 之间的就是图片的数据了。
http报文的头部已经解析过之后,得到的并不全是图片的数据,还包括了其它的部分。
如下是一个去掉了报文的头部剩下的部分。中间的乱码是图片的数据部分。这里你必须先看一下上面那个参考博客,才可以知道这个东西的结构到底是啥样的?
前面提到了解析http报文头部,可以得到两样有用的数据:
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9twzBmnnpT88lyKL
Content-Length: 845
这里的这个 Content-Type 已经和前面提到的很不相同了,但是这里这个 boundary 是我们需要得到的,它是区分报文头和数据体的关键。但是我这里并不会直接使用它来区分,老实说,我不会使用。 并且这个 Content-Length 的含义也变了,它就是除了头部之外的其它部分的长度。但是其它部分还是每一个部分都含有一个头部,并且每一个部分都使用了 boundary 分割开了。
从上面那个截图所示的字节流中,取出来数据体,对我来说是一个挑战了。所以我决定另辟蹊径,考虑到我这个报文的简单性,我虽然是使用了多实体的方式传输,但是也只是含有一个实体!
我们再看一下上面那个截图的具体结构(显示全部字符)吧:
注意报文实际上可以分为三个部分:从上面的报文头部分到空白行 \r\n,然后是下面的数据体使用 “\r\n” + “–” + boundary + “–” + “\r\n” 结尾。这里是很精确的结构,实际上我可以把从上面报文头下面的数据全部写入文件,这样图片会多加了一段字符在后面,但是不会影响图片的显示。 我以前的博客,做过测试,就算加一个视频,也不会影响图片的显示。但是这里还是要严谨一些,把它给去掉。
具体思路:
把上面的截图所示的那个每个实体单独的头部去掉,但是需要记录那部分整个的大小(包括 \r\n 的大小),所以原来的 getHeader 已经不能使用了,需要进一步处理。
使用变量 headLen 记录图片数据上面报文的大小。
使用变量 tailLen 记录下面数据体部分的大小。
变量 size 是除了头部之外的数据大小(含尾部),这里必须把所有数据读取完,不然程序会阻塞。
变量 picSize 是size去除尾部的大小(完整的图片数据,不多也不少。)
采用一个计数器 count,每次读取之后,count 会加上读取的长度,如果长度超过了图片的长度,说明多读了一部分,只写入需要的那部分就行了(len - (count-picSize)),否则就写入读取的数据。
这样处理,可能有些饶人,但是也是无奈之举,毕竟我既想实现功能,但是又无法处理,只能折中使用这种复杂的方式了。
try (OutputStream out = new BufferedOutputStream(new FileOutputStream(path.toFile()))) {
int size = Integer.parseInt(request.getParam("Content-Length")) - headLen; //总共需要读取的字节大小 注意无法转换会抛异常。
System.out.println("需要读取的长度:" + size + " 头部长度为:" + headLen); //减去头部大小
int picSize = size - tailLen; //图片需要读取的字节数目
System.out.println("图片的大小:" + picSize);
int count = 0; //读取字节计数器
int len = 0;
byte[] data = new byte[1024];
while ((len = in.read(data)) != -1) { //上面已经把数据给取走了再次读取就是请求体了
count += len;
if (count <= picSize ) {
out.write(data, 0, len);
} else {
int surplus = len - (count - picSize);
out.write(data, 0, surplus); //将报文写入文件
System.out.println("最后写入的长度为:" + surplus);
}
if (count == size) break; //退出读取报文
}
计算头部大小及中文乱码问题
因为我需要精确的计算上面报文头部的大小了,所以原来的getHeader方法已经不适合了,每次遇到 \n 跳出循环,会导致我少读取到一个 \n,并且原来读取一个字节,就使用 (char)c 转成字符,也是遇到了一个问题,因为图片可能会以中文名字,而整个名字是会作为参数传递进来的。汉字并不是单字节的,所以如果转成一个字节字符就会出现乱码了,如下图所示:
中文乱码之后,它的长度改变了,从这里就能看出来了。导致我前面依赖长度来读取的方法失败了,程序会阻塞住(或者说卡死了),停止程序之后,图片虽然已经上传成功,但是它会缺少部分字节。但是它居然可以显示,我也不知道是为什么了?可能是因为图片不需要全部数据就可以显示的原因。
所以,我使用了 ByteArrayOutputStream 来处理,我把数据读取这个字节数组输出流里面,然后再转成数组,再使用 UTF-8 转码,就没有问题了。好了,中文的问题解决了。
private String getHeader(InputStream in) {
ByteArrayOutputStream headers = new ByteArrayOutputStream();
try {
while (true) {
int c = in.read();
if (c == '\n' || c == -1) {
headers.write(c);
break;
}
headers.write(c);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] headerArr = headers.toByteArray();
String header = new String(headerArr, Charset.forName("UTF-8")); //直接使用 "UTF-8" 需要处理异常。
headLen += headerArr.length; //这里记录一下大小
System.out.println(header);
return header.toString().trim(); //取出空格字符
}
PicUpload 完整的代码部分
package com.dragon.server;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.Charset;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
public class PicUpload extends ServDragon {
private int headLen; //用于二进制内容头部的长度
private int tailLen; //用于记录二进制内容尾部的长度
private String content_type; // 设置一个变量用来获取 content_type
public PicUpload(Request request, Response response) {
super(request, response);
}
public void doGet() {
//Content-Type: multipart/form-data; boundary=----WebKitFormBoundary9twzBmnnpT88lyKL
String contentType = request.getParam("Content-Type"); //这个 contentType 只能获取到 boundary了
//注意这里我采用字符串的substring方法,从 = 符号截取尾部的字串,获取到 boundary
String boundary = contentType.substring(contentType.lastIndexOf("=")+1);
//尾部的长度为:两个 "\r\n--" + boundary + "--\r\n"
String tail = "\r\n--" + boundary + "--\r\n";
tailLen = tail.getBytes(Charset.forName("UTF-8")).length;
/**
* ------WebKitFormBoundary9twzBmnnpT88lyKL
* Content-Disposition: form-data; name="file"; filename="img.jpg"
* Content-Type: image/jpeg
* */
//含有文件等二进制数据的报文,结构更加复杂,虽然已经只考虑只有一个二进制文件的报文了
InputStream in = new BufferedInputStream(request.getInputStream()); //获取输入流
String line = null;
do {
line = this.getHeader(in);
if ("".equals(line)) break; //读取到空格行就退出,下面就是完全的数据了。
System.out.println("打印获取的line:" + line);
if (line.contains("Content-Type")) {
content_type = line.split(":")[1];
}
} while (true);
//下面就是全部的数据了,第一次解析请求头获取到了 Content-Length,上面获取到了 Content-Type
//现在是万事俱备,只欠东风了!
if (content_type != null) {
// 使用 MIME 判断文件的扩展名,这里就只支持 gif jpg jpeg png
String type = null;
switch (contentType) {
case "image/gif": type = ".gif"; break;
case "image/jpeg": type = ".jpg"; break;
case "image/png": type = ".png"; break;
default: type = ".png"; break;
}
//报文头部取出来,剩下的就是报文体了,取出报文体,写入文件,应该就是一张图片了。
String fileName = UUID.randomUUID().toString() + type;
Path path = Paths.get("D:", "DragonFile", "ServerImg", fileName); //保存到相应的目录
try (OutputStream out = new BufferedOutputStream(new FileOutputStream(path.toFile()))) {
int size = Integer.parseInt(request.getParam("Content-Length")) - headLen; //总共需要读取的字节大小 注意无法转换会抛异常。
System.out.println("需要读取的长度:" + size + " 头部长度为:" + headLen); //减去头部大小
int picSize = size - tailLen; //图片需要读取的字节数目
System.out.println("图片的大小:" + picSize);
int count = 0; //读取字节计数器
int len = 0;
byte[] data = new byte[1024];
while ((len = in.read(data)) != -1) { //上面已经把数据给取走了再次读取就是请求体了
count += len;
if (count <= picSize ) {
out.write(data, 0, len);
} else {
int surplus = len - (count - picSize);
out.write(data, 0, surplus); //将报文写入文件
System.out.println("最后写入的长度为:" + surplus);
}
if (count == size) break; //退出读取报文
}
} catch (IOException e) {
e.printStackTrace();
}
DragonServer.picMap.put(fileName, fileName); //上传成功后,更新图片的记录
//上面是写入文件的操作,下面的代码不能接着上面的写,否则写入文件无法完成最后的刷新功能,导致图片大小有问题
//响应报文
String msg = "<!DOCTYPE html><html><head><meta charset=\"utf-8\"/><title>上传成功</title>"
+ "</head><body><h1>图片上传成功!<h1></body></html>";
byte[] content = msg.getBytes(Charset.forName("UTF-8"));
OutputStream output = new BufferedOutputStream(response.getOutputStream());
byte[] header = response.getHeader("text/html;charset=utf-8", content.length, 200, "OK");
//开始写入响应报文
try {
output.write(header); //写入头
output.write(content); //写入体
output.flush(); //刷新输出流
} catch (IOException e) {
e.printStackTrace();
} finally {
response.close();
}
}
}
private String getHeader(InputStream in) {
ByteArrayOutputStream headers = new ByteArrayOutputStream();
try {
while (true) {
int c = in.read();
if (c == '\n' || c == -1) {
headers.write(c);
break;
}
headers.write(c);
}
} catch (IOException e) {
e.printStackTrace();
}
byte[] headerArr = headers.toByteArray();
String header = new String(headerArr, Charset.forName("UTF-8")); //直接使用 "UTF-8" 需要处理异常。
headLen += headerArr.length; //这里记录一下大小
System.out.println(header);
return header.toString().trim(); //取出空格字符
}
}
使用List集合的方式,但是还是很麻烦,不如使用流方便。
private String getHeader(InputStream in) {
List<Byte> arr = new ArrayList<Byte>();
try {
while (true) {
int c = in.read();
if (c == '\n' || c == -1) {
arr.add((byte) c);
break;
}
arr.add((byte) c);
}
} catch (IOException e) {
e.printStackTrace();
}
Object[] arrays = arr.toArray();
byte[] b = new byte[arrays.length];
for (int i = 0; i < arrays.length; i++) {
b[i] = (Byte) arrays[i];
}
System.out.println("使用集合处理:" + new String(b, Charset.forName("UTF-8")));
return header.toString().trim(); //取出空格字符
}
说明
好了,总共12个类全部完成了,程序的功能还是挺好的。虽然简单,但是收获很多,至少对于 HTTP 的理解,不再只是局限于书本上的知识了,也是经历过了一些简单的实践。我这里的很多处理方法都不是很好,但是作为一个初学者,只要坚持学习下去就行了。对于计算机网络(传输层和应用层有了更深入的理解,说实话,当时我的计算机网络课学得不怎么样,哈哈!现在也算是补课了吧!)做一个综合一些的小demo,也是可以锻炼编程水平的!
结尾来一张图片吧,挺有趣的!