Netty原理详解系列(四)---NIO实战之Http服务

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的原理详解。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 6
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值