专栏首页coolblog.xyz技术专栏基于 Java NIO 实现简单的 HTTP 服务器

基于 Java NIO 实现简单的 HTTP 服务器

1.简介

本文是上一篇文章实践篇,在上一篇文章中,我分析了选择器 Selector 的原理。本篇文章,我们来说说 Selector 的应用,如标题所示,这里我基于 Java NIO 实现了一个简单的 HTTP 服务器。在接下来的章节中,我会详细讲解 HTTP 服务器实现的过程。另外,本文所对应的代码已经上传到 GitHub 上了,需要的自取,仓库地址为 toyhttpd。好了,废话不多说,进入正题吧。

2. 实现

本节所介绍的 HTTP 服务器是一个很简单的实现,仅支持 HTTP 协议极少的特性。包括识别文件后缀,并返回相应的 Content-Type。支持200、400、403、404、500等错误码等。由于支持的特性比较少,所以代码逻辑也比较简单,这里罗列一下:

  1. 处理请求,解析请求头
  2. 响应请求,从请求头中获取资源路径, 检测请求的资源路径是否合法
  3. 根据文件后缀匹配 Content-Type
  4. 读取文件数据,并设置 Content-Length,如果文件不存在则返回404
  5. 设置响应头,并将响应头和数据返回给浏览器。

接下来我们按照处理请求和响应请求两步操作,来说说代码实现。先来看看核心的代码结构,如下:

/**
 * TinyHttpd
 *
 * @author code4wt
 * @date 2018-03-26 22:28:44
 */
public class TinyHttpd {

    private static final int DEFAULT_PORT = 8080;
    private static final int DEFAULT_BUFFER_SIZE = 4096;
    private static final String INDEX_PAGE = "index.html";
    private static final String STATIC_RESOURCE_DIR = "static";
    private static final String META_RESOURCE_DIR_PREFIX = "/meta/";
    private static final String KEY_VALUE_SEPARATOR = ":";
    private static final String CRLF = "\r\n";

    private int port;

    public TinyHttpd() {
        this(DEFAULT_PORT);
    }

    public TinyHttpd(int port) {
        this.port = port;
    }

    public void start() throws IOException {
        // 初始化 ServerSocketChannel
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.socket().bind(new InetSocketAddress("localhost", port));
        ssc.configureBlocking(false);

        // 创建 Selector
        Selector selector = Selector.open();
        
        // 注册事件
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        while(true) {
            int readyNum = selector.select();
            if (readyNum == 0) {
                continue;
            }

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> it = selectedKeys.iterator();
            while (it.hasNext()) {
                SelectionKey selectionKey = it.next();
                it.remove();

                if (selectionKey.isAcceptable()) {
                    SocketChannel socketChannel = ssc.accept();
                    socketChannel.configureBlocking(false);
                    socketChannel.register(selector, SelectionKey.OP_READ);
                } else if (selectionKey.isReadable()) {
                    // 处理请求
                    request(selectionKey);
                    selectionKey.interestOps(SelectionKey.OP_WRITE);
                } else if (selectionKey.isWritable()) {
                    // 响应请求
                    response(selectionKey);
                }
            }
        }
    }
    
    private void request(SelectionKey selectionKey) throws IOException {...}
    private Headers parseHeader(String headerStr) {...}
    private void response(SelectionKey selectionKey) throws IOException {...}
    
    private void handleOK(SocketChannel channel, String path) throws IOException {...}
    private void handleNotFound(SocketChannel channel)  {...}
    private void handleBadRequest(SocketChannel channel) {...}
    private void handleForbidden(SocketChannel channel) {...}
    private void handleInternalServerError(SocketChannel channel) {...}
    private void handleError(SocketChannel channel, int statusCode) throws IOException {...}
    
    private ByteBuffer readFile(String path) throws IOException {...}
    private String getExtension(String path) {...}
    private void log(String ip, Headers headers, int code) {}
}

上面的代码是 HTTP 服务器的核心类的代码结构。其中 request 负责处理请求,response 负责响应请求。handleOK 方法用于响应正常的请求,handleNotFound 等方法用于响应出错的请求。readFile 方法用于读取资源文件,getExtension 则是获取文件后缀。

2.1 处理请求

处理请求的逻辑比较简单,主要的工作是解析消息头。相关代码如下:

private void request(SelectionKey selectionKey) throws IOException {
    // 从通道中读取请求头数据
    SocketChannel channel = (SocketChannel) selectionKey.channel();
    ByteBuffer buffer = ByteBuffer.allocate(DEFAULT_BUFFER_SIZE);
    channel.read(buffer);

    buffer.flip();
    byte[] bytes = new byte[buffer.limit()];
    buffer.get(bytes);
    String headerStr = new String(bytes);
    try {
        // 解析请求头
        Headers headers = parseHeader(headerStr);
        // 将请求头对象放入 selectionKey 中
        selectionKey.attach(Optional.of(headers));
    } catch (InvalidHeaderException e) {
        selectionKey.attach(Optional.empty());
    }
}

private Headers parseHeader(String headerStr) {
    if (Objects.isNull(headerStr) || headerStr.isEmpty()) {
        throw new InvalidHeaderException();
    }

    // 解析请求头第一行
    int index = headerStr.indexOf(CRLF);
    if (index == -1) {
        throw new InvalidHeaderException();
    }

    Headers headers = new Headers();
    String firstLine = headerStr.substring(0, index);
    String[] parts = firstLine.split(" ");

    /*
     * 请求头的第一行必须由三部分构成,分别为 METHOD PATH VERSION
     * 比如:
     *     GET /index.html HTTP/1.1
     */
    if (parts.length < 3) {
        throw new InvalidHeaderException();
    }

    headers.setMethod(parts[0]);
    headers.setPath(parts[1]);
    headers.setVersion(parts[2]);

    // 解析请求头属于部分
    parts = headerStr.split(CRLF);
    for (String part : parts) {
        index = part.indexOf(KEY_VALUE_SEPARATOR);
        if (index == -1) {
            continue;
        }
        String key = part.substring(0, index);
        if (index == -1 || index + 1 >= part.length()) {
            headers.set(key, "");
            continue;
        }
        String value = part.substring(index + 1);
        headers.set(key, value);
    }

    return headers;
}

简单总结一下上面的代码逻辑,首先是从通道中读取请求头,然后解析读取到的请求头,最后将解析出的 Header 对象放入 selectionKey 中。处理请求的逻辑很简单,不多说了。

2.2 响应请求

看完处理请求的逻辑,接下来再来看看响应请求的逻辑。代码如下:

private void response(SelectionKey selectionKey) throws IOException {
    SocketChannel channel = (SocketChannel) selectionKey.channel();
    // 从 selectionKey 中取出请求头对象
    Optional<Headers> op = (Optional<Headers>) selectionKey.attachment();

    // 处理无效请求,返回 400 错误
    if (!op.isPresent()) {
        handleBadRequest(channel);
        channel.close();
        return;
    }

    String ip = channel.getRemoteAddress().toString().replace("/", "");
    Headers headers = op.get();
    // 如果请求 /meta/ 路径下的资源,则认为是非法请求,返回 403 错误
    if (headers.getPath().startsWith(META_RESOURCE_DIR_PREFIX)) {
        handleForbidden(channel);
        channel.close();
        log(ip, headers, FORBIDDEN.getCode());
        return;
    }

    try {
        handleOK(channel, headers.getPath());
        log(ip, headers, OK.getCode());
    } catch (FileNotFoundException e) {
        // 文件未发现,返回 404 错误
        handleNotFound(channel);
        log(ip, headers, NOT_FOUND.getCode());
    } catch (Exception e) {
        // 其他异常,返回 500 错误
        handleInternalServerError(channel);
        log(ip, headers, INTERNAL_SERVER_ERROR.getCode());
    } finally {
        channel.close();
    }
}

// 处理正常的请求
private void handleOK(SocketChannel channel, String path) throws IOException {
    ResponseHeaders headers = new ResponseHeaders(OK.getCode());

    // 读取文件
    ByteBuffer bodyBuffer = readFile(path);
    // 设置响应头
    headers.setContentLength(bodyBuffer.capacity());
    headers.setContentType(ContentTypeUtils.getContentType(getExtension(path)));
    ByteBuffer headerBuffer = ByteBuffer.wrap(headers.toString().getBytes());

    // 将响应头和资源数据一同返回
    channel.write(new ByteBuffer[]{headerBuffer, bodyBuffer});
}

// 处理请求资源未发现的错误
private void handleNotFound(SocketChannel channel)  {
    try {
        handleError(channel, NOT_FOUND.getCode());
    } catch (Exception e) {
        handleInternalServerError(channel);
    }
}

private void handleError(SocketChannel channel, int statusCode) throws IOException {
    ResponseHeaders headers = new ResponseHeaders(statusCode);
    // 读取文件
    ByteBuffer bodyBuffer = readFile(String.format("/%d.html", statusCode));
    // 设置响应头
    headers.setContentLength(bodyBuffer.capacity());
    headers.setContentType(ContentTypeUtils.getContentType("html"));
    ByteBuffer headerBuffer = ByteBuffer.wrap(headers.toString().getBytes());

    // 将响应头和资源数据一同返回
    channel.write(new ByteBuffer[]{headerBuffer, bodyBuffer});
}

上面的代码略长,不过逻辑仍然比较简单。首先,要判断请求头存在,以及资源路径是否合法。如果都合法,再去读取资源文件,如果文件不存在,则返回 404 错误码。如果发生其他异常,则返回 500 错误。如果没有错误发生,则正常返回响应头和资源数据。这里只贴了核心代码,其他代码就不贴了,大家自己去看吧。

2.3 效果演示

分析完代码,接下来看点轻松的吧。下面贴一张代码的运行效果图,如下:

3.总结

本文所贴的代码是我在学习 Selector 过程中写的,核心代码不到 300 行。通过动手写代码,也使得我加深了对 Selector 的了解。在学习 JDK 的过程中,强烈建议大家多动手写代码。通过写代码,并踩一些坑,才能更加熟练运用相关技术。这个是我写 NIO 系列文章的一个感触。

好了,本文到这里结束。谢谢阅读!

本文在知识共享许可协议 4.0 下发布,转载需在明显位置处注明出处

作者:coolblog

本文同步发布在我的个人博客:http://www.coolblog.xyz/?r=cb

本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Java NIO之套接字通道

    前面一篇文章讲了文件通道,本文继续来说说另一种类型的通道 -- 套接字通道。在展开说明之前,咱们先来聊聊套接字的由来。套接字即 socket,最早由伯克利大学的...

    田小波
  • Spring MVC 原理探秘 - 一个请求的旅行过程

    田小波
  • MyBatis 源码分析 - 配置文件解析过程

    由于本篇文章篇幅比较大,所以这里拿出一节对本文进行快速概括。本篇文章对 MyBatis 配置文件中常用配置的解析过程进行了较为详细的介绍和分析,包括但不限于se...

    田小波
  • Postman接口测试之0基础入门教程

    Postman是用于做接口请求测试,无论是前端,后台还是测试人员,都可以用postman来测试接口,用起来非常方便。同时也是一款功能强大的网页调试与发送网页HT...

    用户6367961
  • ServletRequest HttpServletRequest 请求方法 获取请求参数 请求转发 请求包含 请求转发与重定向区别 获取请求头字段

    实际为   HttpServletRequest  或者  ServletRequest,   两者都为接口

    noteless
  • 文加图, 理解Http请求与响应

    在讲解OkHttp之前, 我们首先来个高清大图, 看下http请求的整个步骤, 有个整体概念.

    望天
  • 正确使用状态码

    hedeqiang
  • MapReduce之Combiner合并

    爱学习的孙小白
  • HTTP协议

    HTTP,超文本传输协议(HyperText Transfer Protocol)是互联网上应用最为广泛的一种网络协议

    木瓜煲鸡脚
  • Jquery Ajax请求文件下载操作失败的原因分析及解决办法

    jQuery确实是一个挺好的轻量级的JS框架,能帮助我们快速的开发JS应用,并在一定程度上改变了我们写JavaScript代码的习惯。

    用户5640963

扫码关注云+社区

领取腾讯云代金券