Java 中的简单 HTTP 服务器支持 POST\SSL\Cookie

A Simple HTTP Server in Java

A Simple HTTP Server in Java, Part 2 - POST and SSL

A Simple HTTP Server in Java, Part 3 - Cookies and Keep Alives

Java 中的简单 HTTP 服务器

我经常发现自己需要一个非常简单的 HTTP 服务器来进行诸如模拟外部服务以进行测试之类的事情。在这些情况下,Tomcat 甚至 Jetty 都觉得有点矫枉过正;我只是想要一些可以非常快地开始和停止的东西,这将允许我操纵响应的任何任意字节。我通常每次都从头开始重新编写相同的小型专用应用程序,但我终于在本周崩溃并将其编码为可重用的组件。

图 1:Http 服务器概述

图 1 说明了我的简单 HTTP 服务器的高级结构。主类 HttpServer负责侦听端口 80(或 8080,如果您没有 root 权限)并SocketHandler为每个客户端连接生成一个实例。它的start方法(如下面的清单 1 所示)进入无限循环,为每个接受的连接生成一个新线程,并将新套接字交给一个新套接字SocketHandler,然后等待新的客户端连接。“真正的”Web 服务器将使用线程池来避免线程和内存不足以及整个过程崩溃,但对于简单的模拟/测试目的,这非常有效。

  public void start() throws IOException  {
    ServerSocket socket = new ServerSocket(port);
    System.out.println("Listening on port " + port);
    Socket client;
    while ((client = socket.accept()) != null)  {
      System.out.println("Received connection from " + client.getRemoteSocketAddress().toString());
      SocketHandler handler = new SocketHandler(client, handlers);
      Thread t = new Thread(handler);
      t.start();
    }
  }

清单 1:HttpServer.start

SocketHandler反过来,负责实现大部分 HTTP 协议本身。它的目标是创建一个Request对象,用解析的方法、路径、版本和请求标头填充它,创建一个Response对象并将其交给关联Handler以实际填充和响应客户端。HTTP 请求的大部分实际解析都交给了Request对象本身。这甚至没有尝试实现javax.servlet.http.HttpRequest接口——我的目标是让事情变得简单和灵活。请求解析如清单 2 所示。

  public boolean parse() throws IOException {
    String initialLine = in.readLine();
    log(initialLine);
    StringTokenizer tok = new StringTokenizer(initialLine);
    String[] components = new String[3];
    for (int i = 0; i < components.length; i++) {
      if (tok.hasMoreTokens())  {
        components[i] = tok.nextToken();
      } else  {
        return false;
      }
    }

    method = components[0];
    fullUrl = components[1];

    // Consume headers
    while (true)  {
      String headerLine = in.readLine();
      log(headerLine);
      if (headerLine.length() == 0) {
        break;
      }

      int separator = headerLine.indexOf(":");
      if (separator == -1)  {
        return false;
      }
      headers.put(headerLine.substring(0, separator),
        headerLine.substring(separator + 1));
    }

    if (components[1].indexOf("?") == -1) {
      path = components[1];
    } else  {
      path = components[1].substring(0, components[1].indexOf("?"));
      parseQueryParameters(components[1].substring(
        components[1].indexOf("?") + 1));
    }

    if ("/".equals(path)) {
      path = "/index.html";
    }

    return true;
  }

清单 2:Request.parse

根据 HTTP 标准,Request需要一个 CR-LF 分隔的行列表,其第一行的格式为:VERB PATH VERSION后跟表单中的可变长度标头列表NAME: VALUE和一个表示标头列表完整的结束空行。如果VERB支持实体主体(如 POST 或 PUT),则请求的其余部分就是该实体主体。我只担心GET这里的 s,所以我假设没有实体主体。一旦这个方法完成,假设一切在语法上都是正确的,Request的内部method, path, fullUrlheaders 成员变量已填充。此外,由于我几乎总是需要处理查询参数(即 URL 中 '?' 之后传入的内容),所以我继续在此处解析这些参数,如清单 3 所示。这是与大多数其他 HTTP 服务器不同,后者将这种解析委托给更高级别的框架代码,但对我而言,它非常有用。

  private void parseQueryParameters(String queryString) {
    for (String parameter : queryString.split("&")) {
      int separator = parameter.indexOf('=');
      if (separator > -1) {
        queryParameters.put(parameter.substring(0, separator),
          parameter.substring(separator + 1));
      } else  {
        queryParameters.put(parameter, null);
      }
    }
  }

清单 3:Request.parseQueryParameters

一旦请求被成功解析,控制SocketHandler就可以继续查看它是否有一个Handler用于查询的路径。这表明必须已经有一个处理程序;HttpServers的工作(addHandler如清单 4 所示)是将方法和路径关联到处理程序。

  private Map<String, Map<String, Handler>> handlers;

  public void addHandler(String method, String path, Handler handler) {
    Map<String, Handler> methodHandlers = handlers.get(method);
    if (methodHandlers == null) {
      methodHandlers = new HashMap<String, Handler>();
      handlers.put(method, methodHandlers);
    }
    methodHandlers.put(path, handler);
  }

清单 4:HttpServer.addHandler

这只是将字符串映射到处理程序的映射来实现 - 这样,GET /index.html 可以由与 DELETE /index.html 不同的处理程序处理。所以现在SocketHandler 已经解析出客户端请求的方法和路径,它会查找一个处理程序,如清单 5 所示。

  public void run() {
    BufferedReader in = null;
    OutputStream out = null;

    try {
      in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      out = socket.getOutputStream();

      Request request = new Request(in);
      if (!request.parse()) {
        respond(500, "Unable to parse request", out);
        return;
      }

      boolean foundHandler = false;
      Response response = new Response(out);
      Map<String, Handler> methodHandlers = handlers.get(request.getMethod());
      if (methodHandlers == null) {
        respond(405, "Method not supported", out);
        return;
      }

      for (String handlerPath : methodHandlers.keySet())  {
        if (handlerPath.equals(request.getPath()))  {
          methodHandlers.get(request.getPath()).handle(request, response);
          response.send();
          foundHandler = true;
          break;
        }
      }

      ...

清单 5:SocketHandler.run

如果请求没有正确解析,则SocketHandler立即返回错误代码 500;否则,它会检查是否有给定方法和路径的处理程序。如果是,它会将完全解析Request和新实例化的Response; 该 Handler负责通过响应客户端Response 类。然而,为了稍微简化一些事情,我允许在路径“/*”处安装一个“默认”处理程序,如清单 6 所示。这里,同样,“真实的”Web 服务器允许在将路径与处理程序相关联时具有更大的灵活性- 我在这里故意回避了一个重要的复杂性来源。如果您需要这种灵活性,您可能最好咬紧牙关并运行 Tomcat 或 Jetty。

      if (!foundHandler)  {
        if (methodHandlers.get("/*") != null) {
          methodHandlers.get("/*").handle(request, response);
          response.send();
        } else  {
          respond(404, "Not Found", out);
        }
      }

清单 6:SocketHandler.run 默认处理程序

Response清单 7 中是一个相对被动的类;它“HTTP 化”了Handler 发送给它的内容,但在其他方面相当简单。HTTP 协议期望服务器以 text HTTP/1.1 STATUS MESSAGE、 from 中标头的可变长度列表 NAME: VALUE、空行和响应正文进行响应。本Handler应该设置一个响应代码,调用addHeader一次为每个响应头,它计划返回,加体,然后调用send来完成的响应。

  public void setResponseCode(int statusCode, String statusMessage) {
    this.statusCode = statusCode;
    this.statusMessage = statusMessage;
  }

  public void addHeader(String headerName, String headerValue)  {
    this.headers.put(headerName, headerValue);
  }

  public void addBody(String body)  {
    headers.put("Content-Length", Integer.toString(body.length()));
    this.body = body;
  }

  public void send() throws IOException {
    headers.put("Connection", "Close");
    out.write(("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n").getBytes());
    for (String headerName : headers.keySet())  {
      out.write((headerName + ": " + headers.get(headerName) + "\r\n").getBytes());
    }
    out.write("\r\n".getBytes());
    if (body != null) {
      out.write(body.getBytes());
    }
  }

清单 7:响应

这样就完成了 HTTP 协议(​​很简单,不是吗?),但是处理程序本身呢?该界面非常简单,如清单 8 所示。

public interface Handler  {
  public void handle(Request request, Response response) throws IOException;
}

清单 8:处理程序

由于每个子类Handler只被实例化一次,要与它处理的方法和路径相关联,每个实现都必须是线程安全的。在实践中,通过将所有处理放在handle实现中,这很容易做到。请注意,我在这里没有任何类似的规定javax.servlet.http.HttpSession- 同样,这比我将其用于的存根目的要复杂一些。清单 9 说明了所有 Http 处理程序中最基本的:查找与路径名匹配的文件并返回它的文件处理程序。

public class FileHandler implements Handler {
  public void handle(Request request, Response response) throws IOException {
    try {
      FileInputStream file = new FileInputStream(request.getPath().substring(1));
      response.setResponseCode(200, "OK");
      response.addHeader("Content-Type", "text/html");
      StringBuffer buf = new StringBuffer();
      // TODO this is slow
      int c;
      while ((c = file.read()) != -1) {
        buf.append((char) c);
      }
      response.addBody(buf.toString());
    } catch (FileNotFoundException e) {
      response.setResponseCode(404, "Not Found");
    }
  }
}

清单 9:FileHandler

最后,清单 10 说明了这个简单库的示例使用,它返回路径“/hello”的自定义 HTML 响应,并通过查找文件(或返回 404)来响应每个其他请求。

    HttpServer server = new HttpServer(8080);
    server.addHandler("GET", "/hello", new Handler() {
      public void handle(Request request, Response response) throws IOException {
        String html = "<body>It works, " + request.getParameter("name") + "</body>";
        response.setResponseCode(200, "OK");
        response.addHeader("Content-Type", "text/html");
        response.addBody(html);
      }
    });
    server.addHandler("GET", "/*", new FileHandler());  // Default handler
    server.start();

清单 10:简单的模拟 HTTP 服务器

清单 11 包含了这篇博文中的所有源代码,以及我在前面的清单中省略的一些无关内容。

import java.util.Map;
import java.util.HashMap;
import java.io.BufferedReader;
import java.io.IOException;
import java.util.StringTokenizer;

public class Request  {
  private String method;
  private String path;
  private String fullUrl;
  private Map<String, String> headers = new HashMap<String, String>();
  private Map<String, String> queryParameters = new HashMap<String, String>();
  private BufferedReader in; 

  public Request(BufferedReader in)  {
    this.in = in;
  }

  public String getMethod()  {
    return method;
  }

  public String getPath()  {
    return path;
  }

  public String getFullUrl()  {
    return fullUrl;
  }

  // TODO support mutli-value headers
  public String getHeader(String headerName)  {
    return headers.get(headerName);
  }

  public String getParameter(String paramName)  {
    return queryParameters.get(paramName);
  }

  private void parseQueryParameters(String queryString)  {
    for (String parameter : queryString.split("&"))  {
      int separator = parameter.indexOf('=');
      if (separator > -1)  {
        queryParameters.put(parameter.substring(0, separator),
          parameter.substring(separator + 1));
      } else  {
        queryParameters.put(parameter, null);
      }
    }
  }

  public boolean parse() throws IOException  {
    String initialLine = in.readLine();
    log(initialLine);
    StringTokenizer tok = new StringTokenizer(initialLine);
    String[] components = new String[3];
    for (int i = 0; i < components.length; i++)  {
      // TODO support HTTP/1.0?
      if (tok.hasMoreTokens())  {
        components[i] = tok.nextToken();
      } else  {
        return false;
      }
    }

    method = components[0];
    fullUrl = components[1];

    // Consume headers
    while (true)  {
      String headerLine = in.readLine();
      log(headerLine);
      if (headerLine.length() == 0)  {
        break;
      }

      int separator = headerLine.indexOf(":");
      if (separator == -1)  {
        return false;
      }
      headers.put(headerLine.substring(0, separator),
        headerLine.substring(separator + 1));
    }

    // TODO should look for host header, Connection: Keep-Alive header, 
    // Content-Transfer-Encoding: chunked

    if (components[1].indexOf("?") == -1)  {
      path = components[1];
    } else  {
      path = components[1].substring(0, components[1].indexOf("?"));
      parseQueryParameters(components[1].substring(
        components[1].indexOf("?") + 1));
    }

    if ("/".equals(path))  {
      path = "/index.html";
    }

    return true;
  }

  private void log(String msg)  {
    System.out.println(msg);
  }

  public String toString()  {
    return method  + " " + path + " " + headers.toString();
  }
}

import java.io.IOException;
import java.io.OutputStream;
import java.util.Map;
import java.util.HashMap;

/** 
 * Encapsulate an HTTP Response.  Mostly just wrap an output stream and
 * provide some state.
 */
public class Response  {
  private OutputStream out;
  private int statusCode;
  private String statusMessage;
  private Map<String, String> headers = new HashMap<String, String>();
  private String body;

  public Response(OutputStream out)  {
    this.out = out;
  }

  public void setResponseCode(int statusCode, String statusMessage)  {
    this.statusCode = statusCode;
    this.statusMessage = statusMessage;
  }

  public void addHeader(String headerName, String headerValue)  {
    this.headers.put(headerName, headerValue);
  }

  public void addBody(String body)  {
    headers.put("Content-Length", Integer.toString(body.length()));
    this.body = body;
  }

  public void send() throws IOException  {
    headers.put("Connection", "Close");
    out.write(("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n").getBytes());
    for (String headerName : headers.keySet())  {
      out.write((headerName + ": " + headers.get(headerName) + "\r\n").getBytes());
    }
    out.write("\r\n".getBytes());
    if (body != null)  {
      out.write(body.getBytes());
    }
  }
}

import java.io.IOException;
import java.util.Map;
import java.io.BufferedReader;
import java.io.OutputStream;

/**
 * Handlers must be thread safe.
 */
public interface Handler  {
  public void handle(Request request, Response response) throws IOException;
}

import java.io.IOException;
import java.util.Map;
import java.util.HashMap;
import java.net.ServerSocket;
import java.net.Socket;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.BufferedReader;

class SocketHandler implements Runnable  {
  private Socket socket;
  private Handler defaultHandler;
  private Map<String, Map<String, Handler>> handlers;

  public SocketHandler(Socket socket, 
                       Map<String, Map<String, Handler>> handlers)  {
    this.socket = socket;
    this.handlers = handlers;
  }

  /**
   * Simple responses like errors.  Normal reponses come from handlers.
   */
  private void respond(int statusCode, String msg, OutputStream out) throws IOException  {
    String responseLine = "HTTP/1.1 " + statusCode + " " + msg + "\r\n\r\n";
    log(responseLine);
    out.write(responseLine.getBytes());
  }

  public void run()  {
    BufferedReader in = null;
    OutputStream out = null;

    try  {
      in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      out = socket.getOutputStream();

      Request request = new Request(in);
      if (!request.parse())  {
        respond(500, "Unable to parse request", out);
        return;
      }

      // TODO most specific handler
      boolean foundHandler = false;
      Response response = new Response(out);
      Map<String, Handler> methodHandlers = handlers.get(request.getMethod());
      if (methodHandlers == null)  {
        respond(405, "Method not supported", out);
        return;
      }

      for (String handlerPath : methodHandlers.keySet())  {
        if (handlerPath.equals(request.getPath()))  {
          methodHandlers.get(request.getPath()).handle(request, response);
          response.send();
          foundHandler = true;
          break;
        }
      }
      
      if (!foundHandler)  {
        if (methodHandlers.get("/*") != null)  {
          methodHandlers.get("/*").handle(request, response);
          response.send();
        } else  {
          respond(404, "Not Found", out);
        }
      }
    } catch (IOException e)  {
      try  {
        e.printStackTrace();
        if (out != null)  {
          respond(500, e.toString(), out);
        }
      } catch (IOException e2)  {
        e2.printStackTrace();
        // We tried
      }
    } finally  {
      try  {
        if (out != null)  {
          out.close();
        }
        if (in != null)  {
          in.close();
        }
        socket.close();
      } catch (IOException e)  {
        e.printStackTrace();
      }
    }
  }

  private void log(String msg)  {
    System.out.println(msg);
  }
}

public class HttpServer  {
  private int port;
  private Handler defaultHandler = null;
  // Two level map: first level is HTTP Method (GET, POST, OPTION, etc.), second level is the
  // request paths.
  private Map<String, Map<String, Handler>> handlers = new HashMap<String, Map<String, Handler>>();

  // TODO SSL support
  public HttpServer(int port)  {
    this.port = port;
  }

  /**
   * @param path if this is the special string "/*", this is the default handler if
   *   no other handler matches.
   */
  public void addHandler(String method, String path, Handler handler)  {
    Map<String, Handler> methodHandlers = handlers.get(method);
    if (methodHandlers == null)  {
      methodHandlers = new HashMap<String, Handler>();
      handlers.put(method, methodHandlers);
    }
    methodHandlers.put(path, handler);
  }

  public void start() throws IOException  {
    ServerSocket socket = new ServerSocket(port);
    System.out.println("Listening on port " + port);
    Socket client;
    while ((client = socket.accept()) != null)  {
      System.out.println("Received connection from " + client.getRemoteSocketAddress().toString());
      SocketHandler handler = new SocketHandler(client, handlers);
      Thread t = new Thread(handler);
      t.start();
    }
  }

  public static void main(String[] args) throws IOException  {
    HttpServer server = new HttpServer(8080);
    server.addHandler("GET", "/hello", new Handler()  {
      public void handle(Request request, Response response) throws IOException  {
        String html = "It works, " + request.getParameter("name") + "";
        response.setResponseCode(200, "OK");
        response.addHeader("Content-Type", "text/html");
        response.addBody(html);
      }
    });
    server.addHandler("GET", "/*", new FileHandler());  // Default handler
    server.start();
  }
}

清单 11:完整(小型)HTTP 服务器

在我的下一篇文章中,我将扩展它以包括对 POST 请求和分块响应主体的支持 - 即长度未知或无法放入内存的响应主体。

支持 POST 和 SSL 的 HTTP 服务器

上次,我走过了一个简单的Java HTTP服务器的开发。该服务器中缺少的两个主要功能是缺乏对 HTTP POST 的支持以及对 HTTPS 的支持。我会在这篇文章中纠正两者。

邮政机构

HTTP 的最早用例是从远程服务器检索静态文档,因此对GET动词的突出支持。 GET指定客户端正在查找的文档,但传输被认为是单向的:服务器不会根据 GET 请求更改自己的内部状态。尽管 Web 应用程序实现者使用 URL 参数和 cookie 找到了解决此问题的方法,但更完整的方法是POST动词,它不同于GET因为它在 HTTP 标头之后包含任意数据,就像 HTTP 响应一样。这意味着客户端必须向服务器表明它发送了多少数据,以便服务器知道何时停止读取。HTTP 支持两种实现方式:首先,通过 Content-Length 标头预先明确声明 POST 正文中的字节数,其次通过在每个“数据块”前面加上其长度(称为 Chunked传输编码)。两者中较容易支持的是 Content-Length,我将首先实现它。

内容长度标头

回想一下上一篇文章,该类Request负责解析命令行和标题。这在GET和之间没有变化POST;两者之间的区别在于,一旦在GET请求中接收到最后一个标头,就可以有效地认为输入已耗尽。我将把它留给请求处理程序来弄清楚如何处理正文,而不是尝试在请求解析器中处理它,而是为getBody处理程序提供一个调用,以便在它准备好时获取请求的“其余部分”:

public class Request  {
  ...
  public InputStream getBody() throws IOException {
    return new HttpInputStream(in, headers);
  }

清单 1:getBody 函数

getBody,反过来,指的是一个新类的实例,该类HttpInputStream 包装了解析标头以查找内容长度的逻辑,并在数据完全消耗时向调用者指示。

class HttpInputStream extends InputStream  {
  private Reader source;
  private int bytesRemaining;

  public HttpInputStream(Reader source, Map<String, String> headers) throws IOException  {
    this.source = source;

    try  {
      bytesRemaining = Integer.parseInt(headers.get("Content-Length"));
    } catch (NumberFormatException e)  {
      throw new IOException("Malformed or missing Content-Length header");
    }
  }

  public int read() throws IOException  {
    if (bytesRemaining == 0)  {
      return -1;
    } else  {
      bytesRemaining -= 1;
      return source.read();
    }
  }
}

清单 2:HttpInputStream

HttpInputStream查找Content-Length标头,跟踪已读取的字节数,并在所有字节都被消耗后返回 -1(根据 java.io.InputStream规范)。需要注意的是刚回国source.read() 就不能在这里工作; source指的是 Socket InputStream 的一个实例,它永远不会返回 -1,除非底层套接字本身关闭,这不是我们在这里想要做的。

最早版本的 HTTP 确实以这种方式工作,通过关闭套接字的输入端来指示请求的结束。这已更改为支持 HTTP KeepAlive 语义,其中单个套接字可用于为多个请求提供服务。

就是这样;服务器现在支持 POST,至少当 POST 正文在请求标头中明确说明时。清单 3 中显示了如何使用它的示例:

server.addHandler("POST", "/login", new Handler() {
  public void handle(Request request, Response response) throws IOException {
     StringBuffer buf = new StringBuffer();
     InputStream in = request.getBody();
     int c;
     while ((c = in.read()) != -1) {
       buf.append((char) c);
     }
     String[] components = buf.toString().split("&");
     Map<String, String> urlParameters = new HashMap<String, String>();
     for (String component : components) {
       String[] pieces = component.split("=");
       urlParameters.put(pieces[0], pieces[1]);
     }
     String html = "<body>Welcome, " + urlParameters.get("username") + "</body>";

     response.setResponseCode(200, "OK");
     response.addHeader("Content-Type", "text/html");
     response.addBody(html);
  }
});

清单 3:模拟登录处理程序

您可以使用 curl 命令来测试这一点,例如:

$ curl -d "username=jdavies&password=secret" http://localhost:8080/login

分块传输编码

当然,这是最简单的情况 - HTTP 支持更复杂的情况,即客户端正在流式传输未知大小的数据,在这种情况下,发送方负责将数据分成已知大小的块,并在每个块前面加上其长度。挂起块的大小以 CRLF 分隔的 ASCII 格式十六进制提供。因此,例如,如果下一个块的长度为 16,372 字节(0x3ff4),则该块将由字节序列作为前缀:

\r\n3ff4\r\n

这 8 个字节中的每一个都必须存储,然后由 HttpInputStream 丢弃,并向调用者提供 16,372 个字节。第 16,373 个字节必须是 \r,开始另一个块。发送方通过将块大小标记为 0(特别是\r\n0\r\n)来指示完成。一个小问题是第一个块大小没有在 CRLF 之前——或者更确切地说,它是,但 CRLF 是标头列表完整的指示符。因为它已经被头解析器消耗了,我必须做一些特殊的处理来区别对待第一个块长度指示符和其余的指示符。清单 4 显示了支持分块行为所需的对 HttpInputStream 的更改:请注意,这些更改对此类完全本地化,并且对调用者完全透明。

class HttpInputStream extends InputStream  {
  private Reader source;
  private int bytesRemaining;
  private boolean chunked = false;

  public HttpInputStream(Reader source, Map<String, String> headers) throws IOException  {
    this.source = source;

    String declaredContentLength = headers.get("Content-Length");
    if (declaredContentLength != null)  {
      try  {
        bytesRemaining = Integer.parseInt(declaredContentLength);
      } catch (NumberFormatException e)  {
        throw new IOException("Malformed or missing Content-Length header");
      }
    }  else if ("chunked".equals(headers.get("Transfer-Encoding")))  {
      chunked = true;
      bytesRemaining = parseChunkSize();
    }
  }

  private int parseChunkSize() throws IOException {
    int b;
    int chunkSize = 0;

    while ((b = source.read()) != '\r') {
      chunkSize = (chunkSize << 4) |
        ((b > '9') ?
          (b > 'F') ?
            (b - 'a' + 10) :
            (b - 'A' + 10) :
          (b - '0'));
    }
    // Consume the trailing '\n'
    if (source.read() != '\n')  {
      throw new IOException("Malformed chunked encoding");
    }

    return chunkSize;
  }

  public int read() throws IOException  {
    if (bytesRemaining == 0)  {
      if (!chunked) {
        return -1;
      } else  {
        // Read next chunk size; return -1 if 0 indicating end of stream
        // Read and discard extraneous \r\n
        if (source.read() != '\r')  {
          throw new IOException("Malformed chunked encoding");
        }
        if (source.read() != '\n')  {
          throw new IOException("Malformed chunked encoding");
        }
        bytesRemaining = parseChunkSize();

        if (bytesRemaining == 0)  {
          return -1;
        }
      } 
    }

    bytesRemaining -= 1;
    return source.read();
  }
}

清单 4:支持分块传输编码的 HttpInputStream

这里唯一可能令人困惑的部分是我如何解析块大小。假设我得到块大小3FF4:我将首先读取字节“3”,ascii 代码 51。我将从“0”(ASCII 代码 48)中减去它以获得数值 3,并将其存储在 chunkSize 变量中. 所以,到目前为止,块大小是 3。下一个字节是“F”,ascii 代码 70。我将从 ascii 代码“A”(65)中减去它,然后加回 10 以获得正确的值 15那个字符代码。然后我将剩余的字节移动四位(即乘以 16)并将新值 15 插入新的低阶 nybble。表 1 逐字节总结了此处发生的情况。

字节读取十六进制编码累计值
330 << 4 = 0 | 3 = 3
F153 << 4 = 48 | 15 = 63
F1563 << 4 = 1008 | 15 = 1023
4151023 << 4 = 16368 | 4 = 16372

注意x << m | n完全等同于x * 2 m + n,但这个实现只是快了一点。

parseChunkSize实际上等效于Integer.parseInt(s, 16),但在这里使用 Java 库并没有真正的好处,因为我仍然需要收集 a 中的字符StringBuffer才能使用它。

否则,我会查看构造函数中的标头以找出我正在处理的编码类型,然后在阅读器中进行处理。请注意,我通过确保在预期时包含字节 \r 和 \n 来对异常处理表示赞同,但是损坏的或恶意的客户端很容易崩溃或至少挂起此实现。

将 an 转换InputStream为 aBufferedReader然后再转换回 an 确实让我有点痛苦InputStream——但我真的看不出有什么令人信服的理由Reader在这种情况下花时间实现接口,所以我将保持原样。

SSL 支持

我将在这篇文章中进行的最后一项更改是对 SSL 的支持。SSL 最初是为了支持 HTTP 而创建的,因此与使用相同端口进行安全和非安全连接的大多数其他安全协议版本不同,HTTP 通过侦听不同端口进行安全连接来“欺骗”。如果客户端连接到“主”端口(默认为 80),服务器希望下一个字节是 HTTP 消息的一部分。如果客户端连接到安全端口(默认为 443),则服务器希望在 HTTP 消息开始之前进行 SSL 连接协商——否则,SSL 的存在对客户端和服务器来说是透明的,正如它设计的那样成为。

默认情况下,Java 通过javax.net.ssl.SSLServerSocket 该类包含对安全套接字的支持。您可以修改 HTTP 服务器中的启动代码,如清单 5 所示:

public void start() throws IOException	{
    SSLServerSocketFactory factory = (SSLServerSocketFactory) SSLServerSocketFactory.getDefault();
    ServerSocket socket = factory.createServerSocket(securePort);

    Socket client;
    while ((client = socket.accept()) != null)  {

清单 5:带有(非工作)SSL 服务器套接字的 HTTP 服务器

它将编译并运行 - 但是,如果您尝试连接到它,您将收到一条错误消息:

$ curl -v https://localhost:8443/hello
* STATE: INIT => CONNECT handle 0x6000579e0; line 1404 (connection #-5000)
* Added connection 0. The cache now contains 1 members
* STATE: CONNECT => WAITRESOLVE handle 0x6000579e0; line 1440 (connection #0)
*   Trying ::1...
* TCP_NODELAY set
* STATE: WAITRESOLVE => WAITCONNECT handle 0x6000579e0; line 1521 (connection #0)
* Connected to localhost (::1) port 8443 (#0)
* STATE: WAITCONNECT => SENDPROTOCONNECT handle 0x6000579e0; line 1573 (connection #0)
* Marked for [keep alive]: HTTP default
* ALPN, offering h2
* ALPN, offering http/1.1
* Cipher selection: ALL:!EXPORT:!EXPORT40:!EXPORT56:!aNULL:!LOW:!RC4:@STRENGTH
* successfully set certificate verify locations:
  CAfile: /etc/pki/tls/certs/ca-bundle.crt
  CApath: none
* TLSv1.2 (OUT), TLS header, Certificate Status (22):
* TLSv1.2 (OUT), TLS handshake, Client hello (1):
* STATE: SENDPROTOCONNECT => PROTOCONNECT handle 0x6000579e0; line 1587 (connection #0)
* TLSv1.2 (IN), TLS header, Unknown (21):
* TLSv1.2 (IN), TLS alert, Server hello (2):
* error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure
* Marked for [closure]: Failed HTTPS connection
* multi_done
* stopped the pause stream!
* Closing connection 0
* The cache now contains 0 members
* Expire cleared
curl: (35) error:14077410:SSL routines:SSL23_GET_SERVER_HELLO:sslv3 alert handshake failure

示例 1:SSL 连接失败

Java 库在使安全套接字支持尽可能透明方面投入了大量工作,但 SSL 握手本身要求向客户端提供服务器证书,因此在 Java 中向 HTTP 服务器添加 SSL 支持的大部分工作是以管理此证书为中心。

在您可以将它包含在服务器代码中之前,您必须先拥有证书。该证书必须由客户信任的证书颁发机构“签署”——商业网站中使用 Verisign 和 Thawte 等证书颁发机构用于此目的,并且可以收取至少几百美元以换取他们的批准印章。不过,出于测试目的,您可以生成“自签名”证书并将其导入客户端。Java 的 keytool实用程序可以轻松创建自签名证书并使用它来保护服务器套接字。

$ keytool -genkey -keyalg RSA -alias httpserver -keystore httpserver.jks -storepass password
What is your first and last name?
  [Unknown]:  localhost
What is the name of your organizational unit?
  [Unknown]:
What is the name of your organization?
  [Unknown]:
What is the name of your City or Locality?
  [Unknown]:
What is the name of your State or Province?
  [Unknown]:
What is the two-letter country code for this unit?
  [Unknown]:
Is CN=localhost, OU=Unknown, O=Unknown, L=Unknown, ST=Unknown, C=Unknown correct?
  [no]:  yes

Enter key password for 
        (RETURN if same as keystore password):

Warning:
The JKS keystore uses a proprietary format. It is recommended to migrate to PKCS12 which 
is an industry standard format using "keytool -importkeystore -srckeystore httpserver.jks 
-destkeystore httpserver.jks -deststoretype pkcs12".

示例 2:自签名证书生成

这将创建一个名为的新文件httpserver.jks,其中包含一个加密的密钥对(就本示例而言,您可以忽略有关格式的警告)。您可以按照示例 3 所示启动服务器以识别这个新的密钥库/自签名证书(Java 运行时足够“聪明”,可以识别出这里只有一个证书并使用它):

java \
	-Djavax.net.ssl.keyStore=./httpserver.jks \
  -Djavax.net.ssl.keyStorePass=password \
  -classpath . \
  com.jdavies.http.HttpServer

示例 3:使用密钥库启动服务器

但是如果你想使用它,你还没有完全脱离困境:

$ curl https://localhost:8443/hello
curl: (60) SSL certificate problem: self signed certificate
More details here: https://curl.haxx.se/docs/sslcerts.html

curl failed to verify the legitimacy of the server and therefore could not
establish a secure connection to it. To learn more about this situation and
how to fix it, please visit the web page mentioned above.

示例 4:CURL 失败并显示未知证书警告

如果您在浏览器中尝试此操作,您将收到一条安全警告,敦促您不要信任此页面,但您可以单击它并查看该页面。但是,由于我在这里的主要用例是出于测试目的模拟 REST API,因此我必须能够信任该证书。您可以导出自签名证书,然后指示 curl 信任它,如示例 5 所示。

$ keytool -export -keystore ./httpserver.jks -alias httpserver -file httpserver.cer -rfc
Enter keystore password:  password
Certificate stored in file <httpserver.cer>
$ curl --cacert ./httpserver.cer https://localhost:8443/hello?name=josh
<body>It works, josh</body>

示例 5:导出并信任证书

记下-rfc导出命令末尾的参数 - 如果您将其关闭,您将获得 DER(二进制)格式的证书,curl 似乎不接受该证书(即使文档说它应该接受,使用该--cert-type参数)。

下一步

在这一点上,这个 HTTP 服务器对于其最初的预期目的非常有用,即模拟外部依赖项以进行测试。尽管如此,仍有一些不足之处值得解决:对 cookie 的支持,以及对 HTTP 保持活动扩展的支持。我将在下一篇文章中解决这两个问题。

Java 中的 HTTP 服务器,第 3 部分

在我的前两篇文章中,我介绍了用 Java 开发功能性迷你 HTTP 服务器,当我想模拟特定的难以重现的事件(如服务器故障)时,我用它来模拟外部调用。尽管它涵盖了我现在需要的大部分功能(虽然仍远未成为符合规范的 HTTP 服务器),但至少还有两个功能可用于测试特定场景:cookie 处理和持久连接。

饼干

Cookie 允许 HTTP 服务器表现出有状态的行为(相对于特定客户端),而不实际存储任何状态,从而允许独立地处理每个网络请求。从概念上讲,cookies 很简单——服务器向客户端(即浏览器)发送一条信息以“记住”,而客户端负责在每个后续请求中将这条数据发回。

如果您甚至涉足过 HTTP,那么它在 HTTP 标头中实现也就不足为奇了:服务器应该发送一个被调用的响应标头Set-Cookie,客户端应该解析它,然后用它自己的请求标头进行响应Cookie。价值本身有点神秘;的值Set-Cookie是以逗号分隔的键值对列表,每个键值对本身都包含有关 cookie 适用性的元数据。

严格来说,由于我的 mini-HTTP 服务器的实现提供了对请求和响应标头的处理程序访问,服务器实现实际上不必更改以支持 cookie。我可以将所有责任推给各个处理程序,但由于 Cookie 行为遵循一些相当标准的模式,因此值得只做一次。可以通过在表单中​​包含一行来添加基本的 cookie 支持:

response.addHeader("Set-Cookie", "name=value");

在响应处理程序中,然后包括如下代码: 假设只有一个 cookie 有效。尽管响应者可以包含任意数量的 标头,但请求者通常只会在请求中包含一个相应的标头,并将所有 cookie 值连接在一起。因此,如果响应包含以下标头: 下一个请求将具有合并标头: 不过,到目前为止我开发的服务器的一个限制是每个响应的每个标头值只能有一个实例,即cookie 的问题,因为多个 cookie 需要多个标题,每个标题都命名为. 在下面的清单 1 中,我将扩展 该类以处理多个重复的标头。

request.getHeader("Cookie").split("=")[1];

Set-CookieCookie

Set-Cookie: abc=123
Set-Cookie: def=456
Set-Cookie: ghi=789
Cookie: abc=123; def=456; ghi=789

Set-CookieResponse

public class Response {
  private OutputStream out;
  private int statusCode;
  private String statusMessage;
  private Map<String, List<String>> headers = new HashMap<String, List<String>>();
  private String body;

  public Response(OutputStream out) {
    this.out = out;
  }

  public void setResponseCode(int statusCode, String statusMessage) {
    this.statusCode = statusCode;
    this.statusMessage = statusMessage;
  }

  public void addHeader(String headerName, String headerValue)  {
    List<String> headerValues = this.headers.get(headerName);
    if (headerValues == null) {
      headerValues = new ArrayList<String>();
      this.headers.put(headerName, headerValues);
    }

    headerValues.add(headerValue);
  }

  public void addBody(String body)  {
    addHeader("Content-Length", Integer.toString(body.length()));
    this.body = body;
  }

  public void send() throws IOException {
    addHeader("Connection", "Close");
    out.write(("HTTP/1.1 " + statusCode + " " + statusMessage + "\r\n").getBytes());
    for (String headerName : headers.keySet())  {
      Iterator<String> headerValues = headers.get(headerName).iterator();
      while (headerValues.hasNext())  {
        out.write((headerName + ": " + headerValues.next() + "\r\n").getBytes());
      }
    }
    out.write("\r\n".getBytes());
    if (body != null) {
      out.write(body.getBytes());
    }
  }
}

清单 1:多个标头值

在这里,我已将其更改Map<String, String>为 Map<String, List<String>>- 单数标头值现在只是长度为 1 的列表。通过此更改,我可以添加任意数量的 cookie 值。

但是,Set-Cookie标头仍然比简单的名称/值对复杂。每个 cookie 可以包含七段可选的元数据。要包含它们,发件人应在 cookie 规范后附加一个分号,然后包含元数据元素。在这七个中,除了两个之外的所有其他参数都接受附加参数。为了避免处理程序编写者的一些麻烦,创建一个专用类来指定 cookie 是有意义的。这在清单 2 中进行了说明。

public class Cookie     {
  private String name;
  private String value;
  private Date expires;
  private Integer maxAge;
  private String domain;
  private String path;
  private boolean secure;
  private boolean httpOnly;
  private String sameSite;

  public Cookie(String name,
                String value,
                Date expires,
                Integer maxAge,
                String domain,
                String path,
                boolean secure,
                boolean httpOnly,
                String sameSite)        {
    this.name = name;
    this.value = value;
    this.expires = expires;
    this.maxAge = maxAge;
    this.domain = domain;
    this.path = path;
    this.secure = secure;
    this.httpOnly = httpOnly;
    this.sameSite = sameSite;
  }

  public String toString()        {
    StringBuffer s = new StringBuffer();

    s.append(name + "=" + value);

    if (expires != null)    {
      SimpleDateFormat fmt = new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss");
      s.append("; Expires=" + fmt.format(expires) + " GMT");
    }

    if (maxAge != null)     {
      s.append("; Max-Age=" + maxAge);
    }

    if (domain != null)     {
      s.append("; Domain=" + domain);
    }

    if (path != null)       {
      s.append("; Path=" + path);
    }

    if (secure)     {
      s.append("; Secure");
    }

    if (httpOnly)   {
      s.append("; HttpOnly");
    }

    if (sameSite != null)   {
      s.append("; SameSite=" + sameSite);
    }

    return s.toString();
  }
}

清单 2:Cookie 类

简而言之 -ExpiresMax-Age参数指定应该记住 cookie 的时间;如果省略,则不应在当前浏览器会话之后记住 cookie。域和路径指示应将 cookie 返回到当前服务器的哪个子集:如果省略,则客户端应在每次向 cookie 来自的域发出请求时返回 cookie。当然,出于安全原因,没有兼容的浏览器会允许服务器在其域之外设置 cookie,但服务器可以将 cookie 限制在子域中。Secure 和 HttpOnly 分别表示仅当连接是 HTTPS 连接或浏览器发出请求(而不是 JavaScript XMLHttpRequest)时才应返回 cookie ,并且SameSite可以设置为StrictLax指示是否应仅在请求源自 cookie 自己的站点时才发送 cookie。最后三个是防止 XSS 和 CSRF 攻击的安全选项。

使用这个类,处理程序可以实例化一个Cookie实例并addHeader使用Set-Cookiecookie 本身的字符串值进行调用 ,但下面的便捷方法Response使这一点更加清晰:

public void addCookie(Cookie cookie)  {
  addHeader("Set-Cookie", cookie.toString());
}

正如我之前指出的,客户端负责传回 cookie 值——规范要求所有 cookie 都在单个Cookie标头中传输。cookie 标头值是一个以分号 (;) 分隔的名称-值对列表,每个名称-值对都是服务器在某个先前请求中发回的 cookie。客户端不返回元数据;所有元数据值都向客户端指示如何以及何时返回 cookie,因此对服务器没有意义。虽然我有一个Cookie 类,但没有充分的理由将请求 cookie 解析为它的实例,因为它们只是名称/值对;但是,我应该继续解析名称/值对本身并使其易于访问。我将修改头解析例程Request.parse来处理Cookie 标头特别如清单 3 所示。

  String name = headerLine.substring(0, separator);
  String value = headerLine.substring(separator + 2);
  headers.put(name, value);

  if ("Cookie".equals(name))  {
    parseCookies(value);
  }

清单 3:请求解析器更改

假设输入一致,cookie 解析非常简单:

public class Request  {
  ...
  private Map<String, String> cookies = new HashMap<String, String>();

  private void parseCookies(String cookieString)  {
    String[] cookiePairs = cookieString.split("; ");
    for (int i = 0; i < cookiePairs.length; i++)  {
      String[] cookieValue = cookiePairs[i].split("=");
      cookies.put(cookieValue[0], cookieValue[1]);
    }
  }

  public String getCookie(String cookieName)  {
    return cookies.get(cookieName);
  }

清单 4:Cookie 解析器

连接:保持活动

最后一个变化。关于这个服务器,我一直在撒谎:我一直说它是 HTTP/1.1,但它的编码方式不是。这里最大的遗漏是我不尊重 Connection: keep-alive标题。在 HTTP 的第一个修订版中(如果您正在计算,则为 0.9,但继续到 1.0),一旦发送请求并收到响应,客户端就会关闭实际的套接字连接。由于几乎每个有用的 HTTP 交换都涉及到同一个源服务器的多个背靠背请求,因此 HTTP/1.1 引入Connection: Keep-Alive 了客户端可以设置的标头,以指示它希望能够在最后一个请求完成后立即发送另一个 HTTP 请求已发送(这就是为什么Content-Lengthchunked 传输编码非常重要 - 双方没有其他方式可以判断传输何时完成)。例如,如果浏览器下载带有嵌入img标签的页面 ,它可以立即开始通过同一连接请求这些图像,同时它仍在解析响应——事实上,它可以在第一个图像完全下载之前请求下一个图像。客户端通过发送一个Connection: keep-alive标头来表明它愿意并且能够做到这一点,服务器用相应的keep-alive标头或一个Connection: close标头响应, 表明它不能这样做。到目前为止,这就是我的服务器所做的,我测试过的浏览器会优雅地、尽职地打开新连接。不过,尊重保持生命并不过分。

处理连接的套接字在HttpServer.run. 我可以在那里添加一个围绕请求和响应处理程序的循环,并等待客户端指示应该关闭连接。HTTP/1.1 规范实际上规定,如果客户端声明它理解 HTTP/1.1 但省略了Connection标头,则连接应默认保持活动状态。您可能期望客户端会发送一连串的请求,然后是带有 的最终请求Connection: Close,但实际上大多数客户端只是让连接保持打开状态并将其留给服务器在一段时间不活动后超时,因此对于考虑到这一点并在通信暂停后终止连接的实现。

class SocketHandler implements Runnable  {
  ...
  public void run()  {
    BufferedReader in = null;
    OutputStream out = null;

    try  {
      socket.setSoTimeout(10000);
      in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      out = socket.getOutputStream();
      boolean done = false;

      while (!done)  {
        Request request = new Request(in);
        try  {
          if (!request.parse())  {
            response(500, "Unable to parse request", out);
            return;
          }
        } catch (SocketTimeoutException e)  {
          break;
        }

        if ("close".equalsIgnoreCase(request.getHeader("connection")))  {
          done = true;
        }
        ...
        response.send(request.getHeader("connection"));

清单 5:持久套接字连接

现在,按照编码,我读取请求,发送完整的响应,然后循环返回并查找后续请求。为了正确支持 HTTP“流水线”,我实际上应该在发送响应的同时寻找下一个请求。我在这里还没有这样做,但是很容易将run方法的后半部分包装在 a 中Runnable并生成另一个线程来这样做——out并且request必须声明为 final(在 1.8 之前的 Java 版本中)和一些错误处理必须洗牌,否则很容易支持。我试过了,并没有看到太多的功能或性能差异,但如果您正在查看 HTTP 实现,则需要注意这一点。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值