Все статьи раздела «Программирование»

Пример простейшего серверного приложения на Java

Программа UploadServer — пример простейшего серверного приложения на языке Java. Приводится исходный код с подробными объяснениями работы каждого фрагмента. Прдставляет интерес для тех, кто только начинает осваивать программирование на языке Java.

Описание программы

Программа UploadServer — простейший узкоспециализированный web-сервер, предназначенный для закачки файлов на компьютер, на котором он запущен. Он был написан мною на языке Java, причем я преследовал две цели: сделать программу, которой удобно перекидывать файлы в локальной (и не только) сети без установки и настройки громоздкого софта, а также сделать простой, но функциональный и богатый разными «фичами» пример серверного приложения на Java.

Порядок работы с программой не менее прост, чем она сама. Вы запускаете программу, указав в командной строке номер порта (он должен быть разрешен в фаерволе для доступа снаружи), после чего с компьютера, где есть файлы для загрузки, заходите на свой компьютер обычным web-браузером. В появившейся форме выбираете нужный файл кнопкой "Browse..." и нажимаете кнопку "Upload". После успешной загрузки файла будет отображена страница с указанием размера и MD5-суммы загруженного файла.

Для работы программы требуется Java Runtime Environment версии не ниже 1.4 (я проверял на 1.6, но если верить документации, все необходимые функции появились не позже 1.4). Скачать последнюю версию платформу можно с официального сайта Java Ссылка на внешний сайт, откроется в новом окне. Если вы планируете не только смотреть на программу, но и модифицировать ее, то вам потребуется еще и Java-компилятор, который вместе с прочими полезными в разработке утилитами входит в состав Java Development Kit (JDK), который можно скачать там же.

Скачать программу

Программа распространяется совершенно бесплатно, ее текст может быть полностью или частично использован в любых приложениях при условии сохранения моего имени и ссылки на данный сайт в исходных текстах. Я не несу никакой ответственности за любое использование программы и не даю никаких гарантий относительно ее работоспособности, применимости в конкретной ситуации или отсутствия в ней ошибок.

Программа поставляется в виде архива содержащего исходный текст (файл UploadServer.java) и откомпилированный класс (файл UploadServer.class)

Для запуска программы наберите в каталоге с откомпилированным файлом UploadServer.class следующую строку:

java -cp . UploadServer <номер порта>

Разбор исходного текста программы

Если вас интересует только применение программы, то дальше можно не читать — скачали и пользуйтесь на здоровье. Если же вы хотите на этом простом примере разобраться в создании сетевых приложений на Java, дальше я подробно, практически построчно разберу устройство программы. Сразу предупреждаю, что я не претендую на правильность стиля программирования, оптимальный выбор алгоритмов и т.п. Просто моя собственная практика обучения программированию говорит о том, что один из наиболее продуктивных путей, это изучение чужих программ и параллельное написание своих, сперва по образу и подобию, а затем полностью самостоятельно.

За каждым блоком кода следует короткий комментарий о его назначении.

Сведения о программе, об авторе, а также лицензионное соглашение.

/**
  UploadServer program - simple file upload web-server
  Author: Denis Volkov (c) 2007
  For information and support visit http://www.denvo.ru
  
  This program is FREEWARE, you are free to use any part of code
  as well as whole program in your development provided that you
  remain original copyrights and site name in your source.
  
  The product is distributed "as is". The author of the Product will 
  not be liable for any consequences, loss of profit or any other kind
  of loss, that may occur while using the product itself and/or together
  with any other software.
*/  

Хорошим тоном считается вставлять эти строки в каждый файл исходного кода, хотя в случае больших проектов лицензию удобнее вынести в отдельный файл, а в исходниках оставить только упоминание об этом файле.

Импорт используемых классов

import java.net.*;
import java.io.*;
import java.security.*;
import java.util.*;
import java.util.regex.*;
import java.nio.charset.*;

 Программа на языке Java состоит из классов, которые группируются в пакеты, образуя иерархическую структуру наподобие файловой системы. При использовании классов из других пакетов, их необходимо импортировать с помощью директивы import. В качестве параметра можно указывать имя конкретного класса или звездочку после имени пакета, что будет означать импорт всех классов из пакета.

Заголовок класса и определение переменных

public class UploadServer extends Thread
{
  private final static String httpHeader = "HTTP/1.1 200 OK\r\n" 
    + "Content-Type: text/html\r\n"
    + "\r\n";
  private final static String uploadFormString = httpHeader
    + "<form method=\"POST\" enctype=\"multipart/form-data\">\r\n"
    + "<input type=\"file\" name=\"upload_file\" size=100>\r\n"
    + "<input type=\"submit\" value=\"Upload\">\r\n"
    + "</form>\r\n\r\n";

  private final Charset streamCharset = Charset.forName("ISO-8859-1"); 

  private ServerSocket serverSocket;
  private Socket clientSocket;

Описание класса начинается с его заголовка, в котором указывается имя класса, тип доступа к нему, имя базового класса при наследовании, имена интерфейсов, реализуемых классом. В нашем случае описывается публичный класс (чтобы его можно было указывать в качестве стартового), который унаследован от класса Thread, поэтому является потоком.
Далее определяются переменные класса (с модификатором static, т.е. они будут общие для всех создаваемых объектов), содержащие строки для формирования ответа клиенту, а также переменные объекта. Переменная httpHeader содержит заголовок ответа. Согласно стандарту, он должен содержать версию протокола, тип ответа в виде числа и текстовое пояснение в первой строке, а также параметр Content-Type, определяющий MIME-тип передаваемых данных. Переменная uploadFormString дополнительно к заголовку содержит HTML-код формы отправки файла на сервер.
В переменной streamCharset хранится ссылка на объект Charset, используемый для преобразования получаемых из сети байтов в символы, а также символов в байты при записи файла. В двух других переменных хранятся ссылки на серверный сокет и клиентский сокет после установления соединения.

Конструктор класса

  UploadServer(int port) throws Exception
  {
    serverSocket = new ServerSocket(port);
    System.err.println("Server ready @" + port);
  }

Конструктор — специальная функция, вызываемая при создании объекта. Здесь конструктор получает в качестве аргумента номер порта, на котором будет создан сервер, создает серверный сокет, слушающий указанный порт, и печатает сообщение об успешном запуске.
При создании сокета может возникнуть исключение, поскольку конструктор не обрабатывает его самостоятельно, а передает исключение в вызывающий код, о чем сообщается в заголовке функции (throws Exception).

Функция, с которой начинается исполнение потока

  public void run()
  {
    while(true)
    {
      try
      {
        clientSocket = serverSocket.accept();
        System.err.println("Client connection accepted from " 
          + clientSocket.getInetAddress().toString());
        // Read and process data from socket
        processConnection();
      }
      catch(Exception e)
      {
        System.err.println("Exception in run's main loop");
        e.printStackTrace();
      }
      // Close socket
      try
      {
        clientSocket.close();
      }
      catch(Exception e) { }
      System.err.println("Client connection closed");
    }
  }

Функция run() вызывается из базового класса Thread в созданном новом потоке. В ней создается бесконечный цикл приема соединений от клиентов. В начале вызывается функция accept серверного сокета, которая ожидает входящего соединения и возвращает сокет для созданного соединения. Адрес клиента, возвращенный функцией getInetAddress(), используется в выдаваемом диагностическом сообщении, затем вызывается processConnection() для приема и обработки данных от клиентов. Когда последняя возвратит управление, соединение с клиентом завершается и программа переходит к ожиданию нового соединения.
В коде функции использованы два блока обработки исключений. Первый ловит все исключения, возникающие при приеме и обработке соединения, в случае возникновения исключения выводится диагностическое сообщение и стек вызовов в точке исключения (функция printStackTrace()). Второй блок просто ловит исключения, который возможны при закрытии клиентского сокета, но никаких действий не предпринимает.

Функция обработки соединения с клиентом — определение переменных

  private void processConnection() throws Exception
  {
    InputStream inStream = clientSocket.getInputStream();
    BufferedReader in = new BufferedReader(
      new InputStreamReader(inStream, streamCharset));
    OutputStream out = clientSocket.getOutputStream();

Вначале создаются объекты для обмена данными с клиентом. Для чтения данных, отправляемых клиентом, сокет предоставляет поток, унаследованный от класса InputStream (мы не знаем, какой будет класс у реального объекта, но нам это и не нужно, т.к. InputStream предоставляет нам необходимый абстрактный интерфейс для чтения данных). Чтобы читать данные из сокета построчно, мы создаем цепочку читателей: первый InputStreamReader читает байты из потока и преобразует их в символы, используя streamCharset, второй BufferedReader озволяет читать строчку вместо посимвольного чтения с проверкой конца строки. Используемый Charset ISO-8859-1 производит перевод байтов в символы и наоборот «1 в 1», поэтому его можно использовать для двоичных файлов без риска искажения данных.
Для передачи данных клиенту используется выходной поток, возвращаемый функцией getOutputStream().

Чтение заголовка клиентского запроса

    // Read requiest header
    String headerLine, firstLine = null;
    Map headerData = new TreeMap();
    Pattern headerPattern = Pattern.compile("([^\\:]+)\\:(.*)");
    while((headerLine = in.readLine()).length() > 0)
    {
      if(firstLine == null)
        firstLine = headerLine;
      else
      {
        Matcher m = headerPattern.matcher(headerLine);
        if(m.matches())
        {
          headerData.put(m.group(1).trim(), m.group(2).trim());
        }
      }
      System.out.println("HEADER: " + headerLine);
    }

Заголовок запроса в протоколе HTTP Ссылка на внешний сайт, откроется в новом окне состоит из первой строки формата
МЕТОД ИМЯ_РЕСУРСА ВЕРСИЯ_ПРОТОКОЛА
за которыми следуют строки формата
ИМЯ_ПАРАМЕТРА: ЗНАЧЕНИЕ_ПАРАМЕТРА
заголовок завершается пустой строкой. Мы читаем данные, отправленные клиентом, построчно до пустой строки, первую из прочитанных строк сохраняем отдельно в переменной firstLine, остальные разбираем с помощью регулярного выражения на имя и значение параметра и сохраняем в индексированной таблице headerData. Для такого разбора предварительно создается объект Pattern, который содержит подготовленное функцией compile(String) регулярное выражение. Функция matcher(String) этого объекта разбирает переданную строку с помощью подготовленного регулярного выражения и создает объект Matcher, содержащий результаты разбора. Функция group(int) объекта Matcher возвращает строки, которые при разборе попали в группы (задаваемые в регулярном выражении круглыми скобками). В отладочно-демонстрационных целях каждая прочитанная строка печатается на консоль.
Строго говоря, протокол допускает разбиение длинных строк в заголовке на две и более (вторая и последующие строки должны начинаться с пробела или табуляции), однако для простоты кода данный случай здесь не обрабатывается.

Обработка запроса типа "GET"

    // Process first line of request
    if(firstLine.startsWith("GET"))
    {
      System.out.println("Send upload form");
      // Show upload form
      out.write(uploadFormString.getBytes());
    }

Если первая строка запроса начинается со строки "GET", клиенту просто отправляется заранее сформированный ответ с HTML-документом, содержащим форму для отправки файла.

Обработка запроса типа "POST", определение переменных

    else if(firstLine.startsWith("POST"))
    {
      // Get body info
      int contentLength = Integer.parseInt((String)(headerData.get("Content-Length")));
      String contentType = (String)(headerData.get("Content-Type"));
      String boundary = "\r\n--" 
        + contentType.substring(contentType.indexOf("boundary=") + 9) 
        + "--";
      System.out.println("File upload, reading body: " + contentLength + " bytes");
      // Prepare to reading
      Pattern fileNamePattern = Pattern.compile("filename=\"([^\"]+)\"");
      OutputStreamWriter writer = null;
      MessageDigest digestMD5 = MessageDigest.getInstance("MD5");
      String fileName = null;
      String prevBuffer = "";
      char[] buffer = new char[16 << 10];
      int totalLength = contentLength;

В случае, если тип запроса клиента начинается со строки "POST", мы предполагаем, что была отправлена форма с загружаемым файлом. Вначале по параметру Content-Length из заголовка определяется длина передаваемых данных. Далее, поскольку данные будут передаваться клиентом в формате MIME, необходимо получить разделитель из параметра Content-Type и сформировать из него строку-разделитель для определения конца передаваемого файла (приложение А второй части описания формата MIME Ссылка на внешний сайт, откроется в новом окне). Для поиска оригинального имени файла (на клиентской машине) формируется регулярное выражение в переменной fileNamePattern. Для подсчета контрольной суммы принятого файла создается объект MessageDigest с использованием алгоритма хэширования MD5. Также определяются переменные для записывателя (writer), имени файла, создается буфер для чтения файла buffer.

Цикл чтения тела запроса, обработка заголовка формы

      // Reading loop
      while(contentLength > 0)
      {
        if(writer == null)
        {
          // Read strings
          String bodyLine = in.readLine();
          contentLength -= bodyLine.length() + 2;
          // Find name of file  
          if(bodyLine.length() > 0)
          {
            Matcher m = fileNamePattern.matcher(bodyLine);
            if(m.find())
            {
              fileName = m.group(1);
            }
          }
          else if(fileName != null)
          {
            OutputStream stream = new FileOutputStream(fileName);
            if(digestMD5 != null)
              stream = new DigestOutputStream(stream, digestMD5);
            writer = new OutputStreamWriter(stream, streamCharset);
          }
          else
            throw new RuntimeException("Name of uploaded file not found");
        }

При отправке формы по HTTP в формате multipart/form-data для каждого поля file в форме создается блок, разделенный строками-ограничителями, который состоит из текстовых полей с именем файла, а также отделенным от них пустой строкой блоком двоичных данных самого файла. В данной части кода мы читаем текстовые поля и ищем в них имя файла с помощью регулярного выражения. Поскольку в данном случае регулярное выражение охватывает не всю строку, а только ее часть, для проверки наличия подстроки используется метод find() вместо метода matches() в разборе заголовка запроса.
После чтения пустой строки производится создание объектов для чтения самого присланного файла. Если имя файла не было найдено, создается исключение типа RuntimeException с поясняющей текстовой строкой. Если же имя было найдено, создается выходной файловый поток в файл с указанным именем в текущем каталоге. Также создается прозрачный поток DigestOutputStream, который при записи в него направляет копию данных в объект digest для подсчета «на лету» контрольной суммы принятого файла. Для преобразования символов в байты для записи создается записыватель с использованием того же Charset, что и при чтении.

Чтение данных отправленного файла и запись их на диск

        else
        {
          // Read data from stream
          int readLength = Math.min(contentLength, buffer.length);
          readLength = in.read(buffer, 0, readLength);
          if(readLength < 0)
            break;
          contentLength -= readLength;
          // Find boundary string
          String curBuffer = new String(buffer, 0, readLength);
          String bothBuffers = prevBuffer + curBuffer;
          int boundaryPos = bothBuffers.indexOf(boundary);
          if(boundaryPos == -1)
          {
            writer.write(prevBuffer, 0, prevBuffer.length());
            prevBuffer = curBuffer;
          }
          else
          {
            writer.write(bothBuffers, 0, boundaryPos);
            break;
          }
        }
        // Write stats
        System.out.print("Read: " + (totalLength - contentLength) 
          + " Remains: " + contentLength + " Total: " + totalLength 
          + " bytes       \r"); 
      }

После того, как были приняты текстовые строки из тела сообщения и создан записыватель, в каждой итерации цикла происходит чтение данных в буфер и поиск среди прочитанных данных сигнатуры конца данных файла, сформированной ранее. Поскольку сигнатура может встретиться на границе двух прочитанных порций данных, используется двойная буферизация с использованием строки prevBuffer: сигнатура ищется в строке bothBuffers, составленной из предыдущей и новой порций прочитанных данных, и в случае, если она не найдена, в файл записываются данные из предыдущей порции. Если же сигнатура найдена, в файл записывается остаток составного буфера до сигнатуры и цикл завершается.
Описанный алгоритм работает правильно только если принятая порция данных больше длины строки-разделителя, что обеспечивается использованием буферизованного чтения (исходный входной поток сокета может вернуть управление из функции read, прочитав лишь 1 байт).
В конце каждой итерации выводится состояние процесса чтения (число прочитанных и оставшихся до конца тела запроса байт).

Завершение приема файла, отправка ответа клиенту

      System.out.println("Done                                                 ");
      writer.close();
      // Finalize digest calculation
      byte[] md5Sum = digestMD5.digest();
      StringBuffer md5SumString = new StringBuffer();
      for(int n = 0; n < md5Sum.length; ++ n)
        md5SumString.append(printByte(md5Sum[n]));
      // Output client info
      String answer = httpHeader + "<p><b>Upload completed, " + totalLength 
        + " bytes, MD5 sum: " + md5SumString.toString() + "</b>"
        + "<p><a href=\"/\">Next file</a>\r\n\r\n";
      out.write(answer.getBytes());
    }
  }

После завершения цикла чтения закрывается записыватель и выводится диагностическое сообщение. Затем вычисляется контрольная сумма MD5, полученный массив байт преобразуется в шестнадцатеричную строку и формируется ответ клиенту об успешной загрузке файла.

Служебная функция перевода байта в шестнадцатеричную строку

  private static String printByte(byte b)
  {
    int bi = ((int)b) & 0xFF;
    if(bi < 16)
      return "0" + Integer.toHexString(bi);
    else
      return Integer.toHexString(bi);
  }

Функция используется для печати MD5-суммы в ответе клиенту. Она получает байт, преобразует его в целое с отбрасыванием старших байтов (поскольку байт — знаковый тип, то в случае отрицательных значений у целого старшие байты будут иметь значение 0xFF). Затем используется статический метод toHexString класса Integer, который преобразует целое в шестнадцатеричную строку. Этот метод печатает только значащие цифры, поэтому для чисел, меньших 16, необходимо дополнить полученную строку символом "0" слева.

Функция main

  public static void main(String[] args)
  {
    try
    {
      if(args.length < 1)
      {
        System.out.println("Usage: UploadServer <port>");
        return;
      }
      UploadServer server = new UploadServer(Integer.parseInt(args[0]));
      server.start();
    }
    catch(Exception e)
    {
      System.err.println("Error in main");
      e.printStackTrace();
    }
  }
}

Исполнение программы на языке Java начинается с функции main класса, который указан в качестве стартового в командной строке. Это статическая функция, она вызывается, когда ни одного объекта данного класса еще не создано. Функция должна быть описана именно так, как в данном примере, иначе при запуске программы возникнет ошибка java.lang.NoSuchMethodError (не найден метод).
Функция main получает параметры командной строки в виде массива строк. В нашем случае функция main проверяет количество аргументов, сравнивая длину массива с единицей, если нет ни одного аргумента, то в консоль печатается информация о формате командной строки и программа завершается. Если же пользователь указал хотя бы один аргумент, то создается объект класса UploadServer, причем конструктору передается первый аргумент, преобразованный в число, а затем у созданного объекта вызывается метод start. Это метод базового класса Thread, который создает новый поток, вызывает в нем метод run и возвращает управление. Таким образом первый поток, в котором была вызвана функция main, завершается, но программа продолжает работать в новом потоке до завершения функции run.

Полезные ссылки