用Java实现一个简易的Http WebServer

Web Server 的功能: 根据用户提供的 Http request, Web Server 返回对应的 Http response. Web Server 也只不过是一个 Application.

预备知识

  • socket 的基本使用
  • HTTP 协议
  • Java 知识

Socket 的基本使用

Socekt 是同一台主机内,, 应用层与传输层之间的 接口 . 两个程序通过彼此的 Socket 接口进行信息交换, 建立在 Socket 上的程序不需要关心底层的实现.

Socket 方法
InetAddress getInetAddress() : 返回socket连接的地址
int getPort(): 返回远程连接这socket的端口
InetAddress getLocalAddress(): 获取该socket绑定的本地地址
int getLocalPort() : 获取这socket绑定的本地端口

HTTP 协议

HTTP 是一个基于 TCP/IP 通信协议来传递数据(HTML 文件, 图片文件, 查询结果等), 是客户端和服务器交互的一种通迅的格式.

Http 请求

Http 响应

首先, 用户浏览器向服务器发送 HTTP 请求来获取资源. 之后服务器回复 HTTP 响应. 在本程序中, HTTP 请求有浏览器发出, Java 程序负责响应用户请求, 并返回 HTTP 响应报文. 它们通过 Socket 接口进行 HTTP 报文交换.

Java 知识

使用到 Java 中的两个主要类来实现 Web Server:

  • java.net.ServerSocket
  • java.net.ServerSocket

下图是 TCP 中客户套接字和服务器套接字的连接方式

ServerSocket 对应上图中的 Welcoming Socket, Socket 对应上图中的 Connection Socket. Welcoming Socket 监听请求, 当收到来自用户的请求, 就为其建立一个 Connection Socket.

实现思路

  1. 服务在指定端口进行监听,等待请求。
  2. 当接收到请求后,建立连接,新建一个线程,准备分析报文。
  3. 分析请求报文,对用户请求的网页进行查找,并读取内容。
  4. 构造响应报文的内容。
  5. 将响应报文发送给请求方。

代码实现

第一个版本

根据 HTTP 报文请求的 URL 返回给用户对应的资源. 代码实现和项目目录结构如下

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;

public class HttpServer {
  public static final String webroot= "version1/WebRoot/";
  public static void main(String[] args) throws Exception {
    /*在80端口监听的 Welcoming Socekt*/
    ServerSocket serverSocket = new ServerSocket(80);
    /* accept 是一个阻塞方法, 会一直监听, 建立连接之后返回一个 Connection Socket */
    System.out.println("服务器端等待请求");
    Socket socket = serverSocket.accept();
    System.out.println("成功建立连接"+socket.toString());
    /*用于获取 socket 接收的服务端请求报文*/
    BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
    /*Http请求行*/
    String httpRequestLine = reader.readLine();
    /*请求的 uri*/
    String uri = httpRequestLine.split(" ")[1];
    String url=webroot+uri;
    /*从磁盘中取出用户请求的资源文件, 并放回给用户*/
    FileInputStream fileInputStream = new FileInputStream(url);
    OutputStream outputStream = socket.getOutputStream();
    byte[] buffer = new byte[1024];
    int length=0;
    while((length=fileInputStream.read(buffer))!=-1){
      outputStream.write(buffer, 0, length);
    }
    System.out.println("成功发送给用户");
    outputStream.flush();
    socket.close();
  }
}

第二个版本

  1. 在第一个版本中假如用户请求一个服务器没有的资源, 服务器会报异常, 这一点需要改进. 资源不存在的时候应该返回 404 界面

  2. 用户可能不止一次的请求服务器, 我们需要使用循环, 不断监听用户请求.

  3. 真实情况下, 服务器不止处理一个用户的请求, 在第一个版本中我们只能处理一个用户的请求, 因此我们需要使用多线程, 同时处理多个用户的请求.

  4. 实现 GET/POST, 并返回标准的 HTTP response代码实现和目录结构如下

代码仓库

Http 服务器:

import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.HashMap;
import java.util.Map;
import java.util.Scanner;
import java.util.Set;

public class WebServer {
  public static void main(String[] args) throws IOException {
    ServerSocket serverSocket=new ServerSocket(8000);
    while(true){
      Socket socket= serverSocket.accept();
      new Thread(new MyHttpServer(socket)).start();
    }
  }
}

/**
 * 处理一个 Connection Socket 的请求的 HttpServer
 */
class MyHttpServer implements Runnable {
  private final String CRLF="\\r\\n";
  private Socket socket;
  private String webroot;
  public MyHttpServer(Socket socket) {
    System.out.println("连接到服务器的用户: "+socket);
    this.socket = socket;
    // 服务器的资源根目录是当前用户的工作目录
    webroot=System.getProperties().getProperty("user.dir");
  }

  /**
   * 目前只是实现了解析 HTTP 请求
   */
  @Override
  public void run() {
    BufferedReader reader=null;
    PrintWriter printWriter=null;
    try {
      // 读取输入
      reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
      // 写输出
      printWriter = new PrintWriter(socket.getOutputStream(), true);

      // 读入请求行
      String requestLine = reader.readLine();
      if(requestLine==null)return;
      // 请求行参数: POST/GET 请求uri HTTP版本
      String[] requstLineArgs = requestLine.split(" ");

      // 读入请求头
      String line = null;
      String[] keyValue = null;
      Map<String, String> requestHeaders = new HashMap<String, String>();
      while ((line = reader.readLine()) != null && !"".equals(line)) {
        int i = line.indexOf(": ");
        requestHeaders.put(line.substring(0,i),line.substring(i+2));
      }
      // 展示HTTP请求行中的参数
      System.out.println("用户"+socket.getPort()+"请求方法: "+requstLineArgs[0]);
      System.out.println("用户"+socket.getPort()+"请求地址: "+requstLineArgs[1]);
      System.out.println("用户"+socket.getPort()+"的HTTP版本: "+requstLineArgs[2]);
      // 展示Http 请求头中的参数
      System.out.println("");
      System.out.println("用户"+socket.getPort()+"请求头参数");
      showMapValue(requestHeaders);
      System.out.println("");

      // 如果是POST方法继续读入请求体
      if ("POST".equals(requstLineArgs[0])) {
        doPost(requstLineArgs, requestHeaders, reader, printWriter);
      }
      // 如果是GET方法就解析uri
      if ("GET".equals(requstLineArgs[0])) {
        doGet(requstLineArgs, requestHeaders, printWriter);
      }

    } catch (IOException e) {
      e.printStackTrace();
    } finally {
      if (reader != null) {
        try {
          reader.close();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
      if (printWriter != null) {
        printWriter.close();
      }
      if(socket!=null){
        try {
          System.out.println("关闭与服务器连接的用户: "+socket);
          System.out.println();
          socket.close();
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    }
  }

  /**
   * 请求行中是 GET 方法
   * @param requestLineArgs
   * @param requestHeaders
   * @param printWriter
   */
  public void doGet(String[] requestLineArgs,
            Map<String,String> requestHeaders,
            PrintWriter printWriter){
    // GET 的 requestLine 的解析
    int i = requestLineArgs[1].indexOf('?');
    // GET 有参数
    if(i!=-1) {
      // 将参数放入到 getArgs 中
      String sourceUri = requestLineArgs[1].substring(0, i);
      String[] s = requestLineArgs[1].substring(i + 1).split("&");
      String[] keyValue = null;
      Map<String, String> getArgs = new HashMap<String, String>();
      for (String ss : s) {
        String[] split = ss.split("=");
        getArgs.put(split[0], split[1]);
      }
      // 展示GET请求链接中的参数
      System.out.println("");
      System.out.println("用户"+socket.getPort()+"GET 中的参数");
      showMapValue(getArgs);
      System.out.println("");

      writeFile(printWriter,requestHeaders,sourceUri);
    }else{ // 没有额外参数, 属于直接访问
      writeFile(printWriter,requestHeaders,requestLineArgs[1]);
    }
  }

  /**
   * 请求行中是 POST 方法, 现在还不会处理POST方法, 先挖坑
   * @param requestLineArgs
   * @param requestHeaders
   * @param reader
   * @param printWriter
   */
  public void doPost(String[] requestLineArgs,
            Map<String,String> requestHeaders,
            BufferedReader reader,
            PrintWriter printWriter){
    String sourceUri=requestLineArgs[1];
    // 还需要读入 requestBody
  }

  /**
   * 将用户请求的文件发送给用户
   * @param printWriter
   * @param requestHeaders
   * @param uri
   */
  public void writeFile(PrintWriter printWriter,
    Map<String,String> requestHeaders,String uri) {
    String url=webroot+uri;
    System.out.println("用户"+socket.getPort()+"请求文件地址: "+url);
    File file = new File(url);
    try {
      String statusLine=null;
      String contentType=null;
      String entityBody=null;
      // 请求的文件存在
       if(file.exists()&&file.isFile()){
        statusLine ="HTTP/1.0 200 OK"+CRLF;
        contentType="Content-Type"  +contentType(file.toString())+CRLF;
        printWriter.print(statusLine);
        printWriter.print(contentType);
        printWriter.print(CRLF); //之前的为响应行+响应头部, 之后的是响应正文, 之间用 CRLF 分隔
         // 将文件内容发送给用户
         Scanner scanner = new Scanner(new FileInputStream(file));
         while (scanner.hasNextLine()) {
           printWriter.println(scanner.nextLine());
         }
         printWriter.flush();
       }
      else{
        statusLine="HTTP/1.0 404 Not Found"+CRLF;
        contentType="Content-Type: text/html"+CRLF;
        entityBody="<HTML>" +
            "<HEAD><TITLE>Not Found</TITLE></HEAD>" +
            "<BODY>Not Found</BODY></HTML>";
         printWriter.print(statusLine);
         printWriter.print(contentType);
         printWriter.print(CRLF); //之前的为响应行+响应头部, 之后的是响应正文, 之间用 CRLF 分隔
         printWriter.print(entityBody);
         printWriter.flush();
      }

    }catch (Exception e) {
      e.printStackTrace();
    }
  }
  /**
   * 根据用户请求的文件类型, 返回符合mime标准的文件类型
   * 目前只实现了请求html文件的功能
   * @param fileName
   * @return
   */
  public String contentType(String fileName){
    if(fileName.endsWith(".htm")||fileName.endsWith(".html")){
      return "text/html";
    }
    return "";
  }
  public void showMapValue(Map<String,String> map){
    Set<Map.Entry<String, String>> entries = map.entrySet();
    for (Map.Entry<String, String> entry : entries) {
      System.out.println(entry.getKey()+": "+entry.getValue());
    }
  }
}

控制台打印:

连接到服务器的用户: Socket[addr=/0:0:0:0:0:0:0:1,port=11148,localport=8000]
用户11147请求方法: GET
用户11147请求地址: /index.html
用户11147的HTTP版本: HTTP/1.1

用户11147请求头参数
Cookie: Idea-48934e7a=d9468800-fffb-4bce-8707-98eda7648a0b; Webstorm-bee48fc1=645e8992-2d9b-4176-af5d-21b7e87ac442
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Upgrade-Insecure-Requests: 1
Connection: keep-alive
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36
Sec-Fetch-Site: none
Sec-Fetch-Dest: document
Host: localhost:8000
Sec-Fetch-User: ?1
Accept-Encoding: gzip, deflate, br
Accept-Language: zh,en-US;q=0.9,en;q=0.8,zh-CN;q=0.7
Sec-Fetch-Mode: navigate

用户11147请求文件地址: E:\\Desktop\\新建文件夹 (2)/index.html
关闭与服务器连接的用户: Socket[addr=/0:0:0:0:0:0:0:1,port=11147,localport=8000]

注意, 该版本并不完美

  1. 在 Linux 环境下好像会出现路径转义错误
  2. 并不能根据请求参数的不同做出不同的响应, 只能响应最基本的请求 HTML 文件功能, 并判断是否响应 404
  3. GET 请求的中文编码问题没解决

参考资料

java-webserver-impl