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

简介: 1.简介本文是上一篇文章实践篇,在上一篇文章中,我分析了选择器 Selector 的原理。本篇文章,我们来说说 Selector 的应用,如标题所示,这里我基于 Java NIO 实现了一个简单的 HTTP 服务器。

1.简介

本篇文章,我们来说说 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 selectedKeys = selector.selectedKeys();
            Iterator 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 op = (Optional) 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 效果演示

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

tinyhttpd1_w

3.总结

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

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

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

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

目录
相关文章
|
5月前
|
C# 图形学 开发者
Unity开发中使用UnityWebRequest从HTTP服务器下载资源。
总之,UnityWebRequest就是游戏开发者手中的万能钓鱼竿,既可以获取文本数据,也能钓上图片资源,甚至是那声音的涟漪。使用UnityWebRequest的时候,你需要精心准备,比如确定URL、配置请求类型和头信息;发起请求;巧妙处理钓获的数据;还需要机智面对网络波澜,处理各种可能出现的错误。按照这样的过程,数据的钓取将会是一次既轻松愉快也效率高效的编程钓鱼之旅。
280 18
|
5月前
|
应用服务中间件 网络安全 数据安全/隐私保护
网关服务器配置指南:实现自动DHCP地址分配、HTTP服务和SSH无密码登录。
哇哈哈,道具都准备好了,咱们的魔术秀就要开始了。现在,你的网关服务器已经魔法满满,自动分配IP,提供网页服务,SSH登录如入无人之境。而整个世界,只会知道效果,不会知道是你在幕后操控一切。这就是真正的数字世界魔法师,随手拈来,手到擒来。
320 14
|
4月前
|
JSON 前端开发 Go
Go语言实战:创建一个简单的 HTTP 服务器
本篇是《Go语言101实战》系列之一,讲解如何使用Go构建基础HTTP服务器。涵盖Go语言并发优势、HTTP服务搭建、路由处理、日志记录及测试方法,助你掌握高性能Web服务开发核心技能。
|
4月前
|
Go
如何在Go语言的HTTP请求中设置使用代理服务器
当使用特定的代理时,在某些情况下可能需要认证信息,认证信息可以在代理URL中提供,格式通常是:
390 0
|
4月前
|
存储 人工智能 Java
java之通过Http下载文件
本文介绍了使用Java实现通过文件链接下载文件到本地的方法,主要涉及URL、HttpURLConnection及输入输出流的操作。
311 0
|
7月前
|
JSON API 数据安全/隐私保护
使用curl命令在服务器上执行HTTP请求
总的来说,curl是一个非常强大的工具,它可以让你在命令行中发送各种类型的HTTP请求。通过学习和实践,你可以掌握这个工具,使你的工作更加高效。
592 30
|
6月前
|
存储 数据库 Python
使用HTTP POST协议将本地压缩数据发送到服务器
总的来说,使用HTTP POST协议将本地压缩数据发送到服务器是一个涉及多个步骤的过程,包括创建压缩文件,设置HTTP客户端,发送POST请求,以及服务器端的处理。虽然这个过程可能看起来复杂,但一旦你理解了每个步骤,就会变得相对简单。
260 19
|
6月前
|
存储 安全 数据安全/隐私保护
HFS-快速创建HTTP服务器
鉴于HFS的操作简便和方便快捷,它在满足快速,临时的文件分享和传输需求上,能够发挥出巨大的作用。只要明确了以上的安全警告,并做好了必需的安全设置,HFS将是一款实用的HTTP服务器工具。
548 9
|
Java
Java NIO系列教程三
​ 今天主要给大家介绍的是Buffer的基本使用这个也是NIO里面最总要的概率之一,里面的操作也是有一些复杂的同时也是需要大家必须要重点掌握的知识点,同时也介绍了一下Selector的用法下一篇文章我们将为大家介绍Pipe管道以及FileLock文件锁这也是NIO里面最后的一分部内容了。
180 0
|
安全 Java API
Java NIO系列教程四【完】-管道-文件锁-异步写入
​ 到此位置NIO的所有的内容都结束了,对于NIO来说主要是各种概念需要大家去理解然后有很多的用法和api也需要大家去熟悉所以想把NIO学懂学好其实并不容易一定要多写案例去测试巩固,也预祝大家能把NIO的知识看懂理顺!!!
171 0