1.概述
在上一篇博客《Netty原理详解系列(三)—NIO实战之心跳服务》中完成了NIO的第一个实战项目,相信对NIO的使用有了一定程度上的了解。但是上一篇博客中的心跳服务的流程却不适用于其他的情况,心跳服务中所有的读写和业务操作几乎是没有延迟的,单个线程足够了。而当我们要处理的业务是一个非常耗时操作如:查询数据库。 这将导致所有请求都被会阻塞。所以会将耗时的任务交给异步线程池来完成。
这篇博客就通过实现简单的http服务例子来将IO线程和工作线程分开来,顺便可以学习一下Http协议的相关知识。
2.Http请求与响应流程
浏览器直接发起一个http请求的所经历的请求与响应流程如下:
- 客户端发起Http请求,并根据http协议封装请求头与请求参数等。
- 服务端接收连接,读取请求的数据包
- 将数据包解码成HttpRequest对象
- 异步处理业务,并将结果封装成HttpResponse对象
- 基于Http协议对HttpResponse进行编码
- 将编码后的数据写入通道中,响应给客户端
3.Http协议报文
Http协议报文是比较简单的,它是一个文本协议。按照一定的规则对内容进行有序的排布。
3.1请求报文(Request)
请求报文如下图所示:
- 第一行请求方法(post、get等),url,http版本 根据空格分割。末尾是换行和回车
- 第二行开始是N个key:value,代表着N个请求头和对应的值
- 之后是一行换行回车 也就是空行
- 最后是请求体
根据上面的请求报文协议的规则,解析这个报文还是很简单的,代码如下:
public static class Request {//自定义简单的httpRequest
String method; // Http方法 get post 等
String url;
String version; // http版本
Map<String, String> heads;// 请求头
String body;// 请求内容
Map<String, String> params; // 请求参数 在url中分割出来 例如/register?username=senlin&age=23
}
// 解析请求报文
private Request decode(byte[] bytes) throws IOException {
Request request = new Request();//创建httpRequest对象
BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(bytes)));
String firstLine = reader.readLine();//每次读取一行
String[] split = firstLine.trim().split(" ");//第一行根据空格分开,分别是方法,url,http版本
request.method = split[0];
request.url = split[1];
request.version = split[2];
// 读取请求头
Map<String, String> heads = new HashMap<>();//请求头是key-value 所以用hashMap来保存
while (true) {
String line = reader.readLine();//读取每一行
if (line.trim().equals("")) break;//读到一行为空表示请求头结束了
String[] split1 = line.split(":");//每一行根据":"进行分割
heads.put(split1[0], split1[1]);//前一个是key,后一个就是value 放入
}
request.heads = heads;//设置请求头
request.params = getUrlParams(request.url);//从url中截取参数
// 读取请求体
request.body = reader.readLine();//最后一行是请求体
return request;
}
private static Map getUrlParams(String url) {
Map<String, String> map = new HashMap<>();
if (!url.contains("?")) return map;//url中带有"?"说明有参数
if (url.split("\\?").length > 0) {//根据"?"分割
String[] arr = url.split("\\?")[1].split("&");//"?"之后的为参数,根据"&"将参数分开来
for (String s : arr) {//参数是key=value的形式出现的
if (s.contains("=")) {//所以根据"="进行分割
String key = s.split("=")[0];//参数名
String value = s.split("=")[1];//参数值
map.put(key, value);
} else map.put(s, null);
}
return map;
} else return map;
}
3.2响应报文(response)
和请求报文很类似,响应报文如下图所示:
- 第一行http版本,状态码,状态信息 根据空格分割。末尾是换行和回车
- 第二行开始是N个key:value,代表着N个响应头和对应的值
- 之后是一行换行回车,同样也是空行
- 最后是响应体
编码http响应报文
public static class Response {//自定义简单的Response
String version;
Map<String, String> headers; // 响应头
int code; // 响应状态码
String body;//返回结果
}
private byte[] encode(Response response) {
StringBuilder builder = new StringBuilder(1024);
//第一行是http版本信息 + 状态码 + 状态码信息 Code中记录了状态码常量和对应的状态码信息
builder.append(response.version).append(" ").append(response.code).append(" ").append(Code.msg(response.code)).append("\r\n");
if (response.body != null && response.body.length() != 0) {//如果有响应体 那么就需要加上内容的长度以及内容的格式
builder.append("Content-Length: ").append(response.body.length()).append("\r\n").append("Contetn-Type: text/html\r\n");
}
if (response.headers != null) {//如果有响应头,遍历响应头,将key-value,拼接成字符串key:value的形式
String headStr = response.headers.entrySet().stream().map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining("\r\n"));
builder.append(headStr + "\r\n");
}
builder.append(response.body);//加上响应体
return builder.toString().getBytes();
}
4.简单的http服务
有了上面对http协议编码和解码的工具。下面就开始实现基于NIO的简单的http服务。用大家都熟悉的HttpServlet进行业务处理。
http服务的代码如下:
public class SimpleHttpServer {
private final Selector selector;//NIO的选择器组件
private final HttpServlet servlet;
private final ExecutorService executors;//工作线程池
private int port;//端口
private ServerSocketChannel listenerChannel;//用于接收连接的通道
// 初始化
public SimpleHttpServer(int port, HttpServlet servlet) throws IOException {
this.port = port;
this.servlet = servlet;
this.selector = Selector.open();//和上一篇博客类似,开启选择器
this.listenerChannel = ServerSocketChannel.open();//开启通道
listenerChannel.bind(new InetSocketAddress(port));//为通道绑定端口
listenerChannel.configureBlocking(false);//设置阻塞模式为false
listenerChannel.register(selector, SelectionKey.OP_ACCEPT);//将通道注册到选择器中,并设置监听模式为ACCEPT
executors = Executors.newFixedThreadPool(5);//初始化工作线程池
}
public Thread start() {//开启IO线程,用于select选择器轮询键集
Thread thread = new Thread(() -> {
while (true) {
try {
dispatch();//轮询方法
} catch (Throwable e) {
e.printStackTrace();
}
}
});
thread.start();
return thread;
}
private void dispatch() throws IOException {
selector.select(500);//轮询键集
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()) {//遍历选择键
SelectionKey key = iterator.next();
iterator.remove();
if (!key.isValid()) continue;
if (key.isAcceptable()) {//若是可接收状态
SocketChannel socketChannel = listenerChannel.accept();//接收连接,并获得一个socketChannel,在上一篇博客中说明了为什么要有一个新的通道,是因为ServerSocketChannel不支持监听读写模式。而SocketChannel可以监听读写模式。
socketChannel.configureBlocking(false);//将阻塞模式设置为false
socketChannel.register(selector, SelectionKey.OP_READ);// 设置监听模式为READ,用于读取客户端的消息
} else if (key.isReadable()) {//若当前键是可读状态
SocketChannel channel = (SocketChannel) key.channel();//拿到键对应的channel
System.out.println(channel);
// 1. 基于buffer读取数据
ByteBuffer buffer = ByteBuffer.allocate(1024);
final ByteArrayOutputStream out = new ByteArrayOutputStream();
while (channel.read(buffer) > 0) {//通道中的数据先写入buffer
buffer.flip();//再次强调,写入buffer后 需要重置position 为读取做准备。不清楚的读者可以看看Netty原理详解系列的第一篇博客。
out.write(buffer.array(), 0, buffer.limit());//从buffer中读取数据,写入到输出流中
buffer.clear();//重置所有标记位
}
if (out.size() == 0) {
channel.close();
continue;
}
// 2. 进行解码
Request request = decode(out.toByteArray());//对http请求报文进行解码
Response response = new Response();
key.attach(response);
// 3. 业务处理 异步 交给工作线程池来完成
executors.submit(() -> {
if (request.method.equalsIgnoreCase("get")) {
servlet.doGet(request, response);
} else {
servlet.doPost(request, response);
}
key.interestOps(SelectionKey.OP_WRITE);//修改监听模式为WRITE
selector.wakeup();// 唤醒IO线程
});
} else if (key.isWritable()) {//若当前是写入状态
Response response = (Response) key.attachment();
// 4.编码
byte[] bytes = encode(response);//对response进行编码
SocketChannel channel = (SocketChannel) key.channel();//拿到键对应的channel
// 5.写入结果
channel.write(ByteBuffer.wrap(bytes));//基于buffer将编码后的结果写入channel,发送给客户端
key.interestOps(SelectionKey.OP_READ);//需改监听模式为READ
}
}
}
// 编码
private byte[] encode(Response response) {
StringBuilder builder = new StringBuilder(1024);
builder.append(response.version).append(" ").append(response.code).append(" ").append(Code.msg(response.code)).append("\r\n");
if (response.body != null && response.body.length() != 0) {
builder.append("Content-Length: ").append(response.body.length()).append("\r\n").append("Contetn-Type: text/html\r\n");
}
if (response.headers != null) {
String headStr = response.headers.entrySet().stream().map(e -> e.getKey() + ":" + e.getValue()).collect(Collectors.joining("\r\n"));
builder.append(headStr + "\r\n");
}
builder.append(response.body);
return builder.toString().getBytes();
}
// 解码http服务
private Request decode(byte[] bytes) throws IOException {
Request request = new Request();
BufferedReader reader = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(bytes)));
String firstLine = reader.readLine();
String[] split = firstLine.trim().split(" ");
request.method = split[0];
request.url = split[1];
request.version = split[2];
// 读取请求头
Map<String, String> heads = new HashMap<>();
while (true) {
String line = reader.readLine();
if (line.trim().equals("")) break;
String[] split1 = line.split(":");
heads.put(split1[0], split1[1]);
}
request.heads = heads;
request.params = getUrlParams(request.url);
// 读取请求体
request.body = reader.readLine();
return request;
}
// username=senlin&age=23
private static Map getUrlParams(String url) {
Map<String, String> map = new HashMap<>();
// url = url.replace("?", ";");
if (!url.contains("?")) return map;
if (url.split("\\?").length > 0) {
String[] arr = url.split("\\?")[1].split("&");
for (String s : arr) {
if (s.contains("=")) {
String key = s.split("=")[0];
String value = s.split("=")[1];
map.put(key, value);
} else map.put(s, null);
}
return map;
} else return map;
}
public abstract static class HttpServlet {
abstract void doGet(Request request, Response response);
abstract void doPost(Request request, Response response);
}
public static class Request {
Map<String, String> heads;// 请求头
String url;
String method; // Http方法 get post
String version; // http版本
String body;// 请求内容
Map<String, String> params; // 请求参数
}
public static class Response {
String version;// http 版本
Map<String, String> headers; // 响应头
int code; // 响应状态码
String body;//返回结果
}
}
编写测试类
public class HttpServerTest {
@Test
public void simpleHttpTest() throws IOException, InterruptedException {
SimpleHttpServer simpleHttpServer = new SimpleHttpServer(8080, new SimpleHttpServer.HttpServlet() {
@Override
void doGet(SimpleHttpServer.Request request, SimpleHttpServer.Response response) {
try {
Thread.sleep(1_000);
System.out.println("模拟业务处理");
} catch (InterruptedException e) {
e.printStackTrace();
}
response.body = "hello world";
response.code = 200;
response.version = "HTTP/1.1";
response.headers = new HashMap<>();
if (request.params.containsKey("short")) {//短连接
response.headers.put("Connection", "close");
} else if (request.params.containsKey("long")) {//长连接
response.headers.put("Connection", "keep-alive");
response.headers.put("Keep-Alive", "timeout=30,max=300");
}
}
@Override
void doPost(SimpleHttpServer.Request request, SimpleHttpServer.Response response) {
}
});
simpleHttpServer.start().join();
}
}
5.结果展示
启动上面的测试代码,在浏览器中输入如下url
F12 可以查看当前发起的http请求的信息,如下所示
对比,我们代码中对http请求报文的解码的结果
可以得出结论是代码是没有问题的,一共14个请求头,2个参数,url和请求方法等都是对得上的。
之后就是处理业务逻辑,然后对response进行编码,编码的结果如下
编码后的结果写入通道,返还给浏览器。在浏览器中的结果如下:
页面输出hello world 也有了响应的状态码和状态信息以及响应头
6.后续
前4章介绍完了NIO的三个重要组件并完成了两个实战demo,相信已经掌握了NIO了。接下来就正式的进入Netty的原理详解。