Http в Java. Часть 2.5 - Простой web server.

пятница, 29 июня 2012 г.
Предыдущая статья оказалась достаточно объемной и в то же время не законченной. Привести пример HTTP клиента и не показать простейшего HTTP сервера, это как сказать "а" и забыть про "б". Пришло время все исправить.


Представление о том, как получить сообщение от клиента и отправить ему ответ, вы можете почерпнуть из первой статьи цикла. Чтобы развить пример tcp сервера и вырастить из него http сервер, надо подружить его с http протоколом. Естественно, поддержка всего протокола сервером - задача не самая простая и очень далеко выходящая за рамки одной статьи. Поэтому приводимая реализация веб-сервера будет ограничена возможностью отдавать точно указанный контент и выводить содержимое запроса в консоль. Практическое применение такому серверу найти не просто, но для экспериментов, при изучении http и особенно ajax, он может пригодиться.

Реализация веб-сервера будет разбита на две части: первая (класс HttpServer) будет отвечать за прием сообщений от клиентов, вторая (класс ClientSession) за их обработку.

Код класса HttpServer представлен ниже:
   
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;

/**
 * Обрабатывает запросы от клиентов, возвращая файлы, указанные 
 * в url-path или ответ с кодом 404, если такой файл не найден.
 *
 */
public class HttpServer {

   /**
    * Первым аргументом может идти номер порта.
    */
   public static void main(String[] args) {
      /* Если аргументы отсутствуют, порт принимает значение поумолчанию */
      int port = DEFAULT_PORT;
      if (args.length > 0) {
         port = Integer.parseInt(args[0]);
      }
      /* Создаем серверный сокет на полученном порту */
      ServerSocket serverSocket = null;
      try {
         serverSocket = new ServerSocket(port);
         System.out.println("Server started on port: "
               + serverSocket.getLocalPort() + "\n");
      } catch (IOException e) {
         System.out.println("Port " + port + " is blocked.");
         System.exit(-1);
      }
      /*
       * Если порт был свободен и сокет был успешно создан, можно переходить к
       * следующему шагу - ожиданию клинтов
       */
      while (true) {
         try {
            Socket clientSocket = serverSocket.accept();
            /* Для обработки запроса от каждого клиента создается
             * отдельный объект и отдельный поток */
            ClientSession session = new ClientSession(clientSocket);
            new Thread(session).start();
         } catch (IOException e) {
            System.out.println("Failed to establish connection.");
            System.out.println(e.getMessage());
            System.exit(-1);
         }
      }
   }

   private static final int DEFAULT_PORT = 9999;
}
   
Приведенный код не должен вызвать затруднений. В нем создается серверный сокет, и при каждом новом подключении клиента его обработка делегируется очередному объекту ClientSession.

Полный код  ClientSession приводится ниже, после идут небольшие пояснения к нему:
   
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.io.PrintStream;
import java.net.Socket;
import java.util.Date;

/**
 * Обрабатывает запрос клиента.
 */
public class ClientSession implements Runnable {

   @Override
   public void run() {
      try {
         /* Получаем заголовок сообщения от клиента */
         String header = readHeader();         
         System.out.println(header + "\n");
         /* Получаем из заголовка указатель на интересующий ресурс */
         String url = getURIFromHeader(header);
         System.out.println("Resource: " + url + "\n");
         /* Отправляем содержимое ресурса клиенту */
         int code = send(url);
         System.out.println("Result code: " + code + "\n");
      } catch (IOException e) {
         e.printStackTrace();
      } finally {
         try {
            socket.close();
         } catch (IOException e) {
            e.printStackTrace();
         }
      }
   }

   public ClientSession(Socket socket) throws IOException {
      this.socket = socket;
      initialize();
   }

   private void initialize() throws IOException {
      /* Получаем поток ввода, в который помещаются сообщения от клиента */
      in = socket.getInputStream();
      /* Получаем поток вывода, для отправки сообщений клиенту */
      out = socket.getOutputStream();
   }

   /**
    * Считывает заголовок сообщения от клиента.
    * 
    * @return строка с заголовком сообщения от клиента.
    * @throws IOException
    */
   private String readHeader() throws IOException {
      BufferedReader reader = new BufferedReader(new InputStreamReader(in));
      StringBuilder builder = new StringBuilder();
      String ln = null;
      while (true) {
         ln = reader.readLine();
         if (ln == null || ln.isEmpty()) {
            break;
         }
         builder.append(ln + System.getProperty("line.separator"));
      }
      return builder.toString();
   }

   /**
    * Вытаскивает идентификатор запрашиваемого ресурса из заголовка сообщения от
    * клиента.
    * 
    * @param header
    *           заголовок сообщения от клиента.
    * @return идентификатор ресурса.
    */
   private String getURIFromHeader(String header) {
      int from = header.indexOf(" ") + 1;
      int to = header.indexOf(" ", from);
      String uri = header.substring(from, to);
      int paramIndex = uri.indexOf("?");
      if (paramIndex != -1) {
         uri = uri.substring(0, paramIndex);
      }
      return DEFAULT_FILES_DIR + uri;
   }

   /**
    * Отправляет ответ клиенту. В качестве ответа отправляется http заголовок и
    * содержимое указанного ресурса. Если ресурс не указан, отправляется
    * перечень доступных ресурсов.
    * 
    * @param url
    *           идентификатор запрашиваемого ресурса.
    * @return код ответа. 200 - если ресурс был найден, 404 - если нет.
    * @throws IOException
    */
   private int send(String url) throws IOException {
      InputStream strm = HttpServer.class.getResourceAsStream(url);
      int code = (strm != null) ? 200 : 404;
      String header = getHeader(code);
      PrintStream answer = new PrintStream(out, true, "UTF-8");
      answer.print(header);
      if (code == 200) {
         int count = 0;
         byte[] buffer = new byte[1024];
         while((count = strm.read(buffer)) != -1) {
            out.write(buffer, 0, count);
         }
         strm.close();
      }
      return code;
   }

   /**
    * Возвращает http заголовок ответа.
    * 
    * @param code
    *           код результата отправки.
    * @return http заголовок ответа.
    */
   private String getHeader(int code) {
      StringBuilder buffer = new StringBuilder();
      buffer.append("HTTP/1.1 " + code + " " + getAnswer(code) + "\n");
      buffer.append("Date: " + new Date().toGMTString() + "\n");
      buffer.append("Accept-Ranges: none\n");
      buffer.append("Content-Type: " + contentType + "\n");
      buffer.append("\n");
      return buffer.toString();
   }

   /**
    * Возвращает комментарий к коду результата отправки.
    * 
    * @param code
    *           код результата отправки.
    * @return комментарий к коду результата отправки.
    */
   private String getAnswer(int code) {
      switch (code) {
      case 200:
         return "OK";
      case 404:
         return "Not Found";
      default:
         return "Internal Server Error";
      }
   }

   private Socket socket;
   private InputStream in = null;
   private OutputStream out = null;
   
   private static final String DEFAULT_FILES_DIR = "/www";
}


 Первое, что делает ClientSession - это получает содержимое запроса и выводит его в стандартный поток вывода. Т.к. данная реализация не предусматривает реакции на параметры запроса, то тело запроса не представляет интереса и чтение самого запроса ограничивается только его заголовком:

if (ln == null || ln.isEmpty()) {
   break;
}
Далее, из заголовка сообщения получается url запрашиваемого ресурса. В соответствии с протоколом http, url вытаскивается из первой строки заголовка как подстрока между первыми двумя пробелами. От url отрезаются параметры запроса (если они присутствуют).

private String getURIFromHeader(String header) {
   int from = header.indexOf(" ") + 1;
   int to = header.indexOf(" ", from);
   String uri = header.substring(from, to);
   int paramIndex = uri.indexOf("?");
   if (paramIndex != -1) {
      uri = uri.substring(0, paramIndex);
   }
   return DEFAULT_FILES_DIR + uri;
}

Для удобства, корневой директорией сервера считается папка www, но это всего лишь несущественная условность.

Для ответа клиенту формируется простейший http заголовок, указывающий код ответа. Доступны два кода: 200 - если запрашиваемый ресурс был найден и всеми любимый 404 - если ресурса не оказалось. Дополнительно в заголовке указывается время и тот факт, что сервер не поддерживает докачку файлов (оба поля не существенны и приводятся чисто формально).

private String getHeader(int code) {
   StringBuffer buffer = new StringBuffer();
   buffer.append("HTTP/1.1 " + code + " " + getAnswer(code) + "\n");
   buffer.append("Date: " + new Date().toGMTString() + "\n");
   buffer.append("Accept-Ranges: none\n");
   buffer.append("\n");
   return buffer.toString();
}

При отправке ответа ресурсы получаются несколько неординарным способом:

private int send(String url) throws IOException {
   // Здесь можно было бы ожидать работу с классом File...
   InputStream strm = HttpServer.class.getResourceAsStream(url);
   int code = (strm != null) ? 200 : 404;
   String header = getHeader(code);
   PrintStream answer = new PrintStream(out, true, "UTF-8");
   answer.print(header);
   if (code == 200) {
      int count = 0;
      byte[] buffer = new byte[1024];
      while((count = strm.read(buffer)) != -1) {
         out.write(buffer, 0, count);
      }
      strm.close();
   }
   return code;
}
Это сделано для того, чтобы можно было уместить весь сервер в одном jar архиве.

Собранный проект вы можете взять здесь . Для запуска требуется java  версии 6 и выше. Команда для запуска проекта: java -jar webserver.jar <номер порта>. Номер порта можно не указывать, тогда сервер будет запущен на 9999 порту:
$>java -jar webserver.jar
Server started on port: 9999

Чтобы проверить работоспособность сервера, перейдите по адресу: http://localhost:9999/index.html В случае успеха должна открыться страница, которая лежит в папке www внутри jar архива с сервером. Для размещения собственных ресурсов на сервере просто добавьте их в папку www внутри архива и перейдите по ссылке http://localhost:9999/<путь>, где <путь> - это это путь к интересующему вас ресурсу относительно папки www внутри архива. Внимание! В <пути> в качестве разделителя необходимо использовать прямой слэш "/". Если путь будет содержать ссылку не на файл, а на директорию, будет выведено содержимое этой директории.

Содержимое, отправленное серверу можно увидеть в окне терминала из которого был запущен сервер. Помимо заголовка запроса будет выведен запрашиваемый адрес и код ответа сервера:
$>java -jar webserver.jar
Server started on port: 9999

GET /index.html HTTP/1.1
Host: localhost:9999
User-Agent: Mozilla/5.0 (Windows NT 5.1; rv:13.0) Gecko/20100101 Firefox/13.0.1
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-ru,ru;q=0.8,en-us;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Connection: keep-alive


Resource: /www/index.html

Result code: 200

Для прекращения работы сервера воспользуйтесь стандартной комбинацией клавиш Ctrl+C

UPD: исправлена ошибка из-за которой страница открывалась как текстовый файл с исходным кодом. В заголовок проставляется тип содержимого Content-Type.

11 комментариев :

  1. Добрый день, вижу не саму страницу, а код в странице
    http://joxi.ru/4Ake4M5cv7lNmq

    ОтветитьУдалить
    Ответы
    1. Проблема в том, что в методе getHeader не проставляется content-type:

      buffer.append("Content-Type: text/html\n");

      Удалить
    2. Этот комментарий был удален автором.

      Удалить
    3. Этот комментарий был удален автором.

      Удалить
    4. Та же самая проблема

      Удалить
    5. Спасибо за замечания! Ошибка исправлена

      Удалить
  2. Добрый день
    Что нужно добавить, чтобы у меня работал css и javascript, когда сервер ответит клиенту?

    ОтветитьУдалить
    Ответы
    1. Добрый =)
      Все дело в правильном Content-Type. Лучше всего, почитать какие значения для этого заголовка допустимы. К примеру на вики: https://ru.wikipedia.org/wiki/%D0%A1%D0%BF%D0%B8%D1%81%D0%BE%D0%BA_MIME-%D1%82%D0%B8%D0%BF%D0%BE%D0%B2#text

      Удалить
  3. Здравствуйте, сервер запускается но я не могу подключиться к страницам, браузеры говорят что не существует такой страницы и в консоль постоянно выводится 404. Пробовал запускать ваш и проект, а так же собирал свой. Никак не могу исправить. В чем я не прав?

    ОтветитьУдалить
    Ответы
    1. увы, не могу Вам точно ответить. Но раз вы получаете 404, то сервер все-таки работает, но не может найти страниц. Попробуйте открыть изображение по адресу http://localhost:9999/index_files/webserver.png Получилось?

      Удалить
  4. Этот комментарий был удален автором.

    ОтветитьУдалить

Ваше мнение мне искренне интересно. Смелее!

Технологии Blogger.