четверг, 1 марта 2012 г.

HttpListener своими руками

Постановка задачи.
Пару недель назад появилась необходимость в прослушивателе протокола HTTP так называемом HttpListener'е. Нужен он был как вспомогательная утилита для юнит тестов. Суть тестов заключалась в том, что при определенных условиях отправлялся запрос на определенный адрес и порт. Затем этот запрос обрабатывался и в зависимости от результатов обработки, приходил ответ с определенным HTTP статус кодом. Результат юнит теста зависел от ответа полученного от прослушивателя.
В данной статье я хотел бы продемонстрировать пошаговую реализацию такого прослушивателя.

Реализация простого прослушивателя с помощью класса HttpListener.
Предлагаю без замедления приступить к базовой реализации слушателя. Будем слушать локальный порт 9999 и на любые запросы возвращать 200 OK:
string url = "http://127.0.0.1";
string port = "9999";
string prefix = String.Format("{0}:{1}/", url, port);

HttpListener listener = new HttpListener();
listener.Prefixes.Add(prefix);
                        
listener.Start();

Console.WriteLine("Welcome to simple HttpListener.\n", port);
Console.WriteLine("Listening on {0}...", prefix);

while (true)
{
    //Ожидание входящего запроса
    HttpListenerContext context = listener.GetContext();

    //Объект запроса
    HttpListenerRequest request = context.Request;

    //Объект ответа
    HttpListenerResponse response = context.Response;

    //Создаем ответ
    string requestBody;
    Stream inputStream = request.InputStream;
    Encoding encoding = request.ContentEncoding;
    StreamReader reader = new StreamReader(inputStream, encoding);
    requestBody = reader.ReadToEnd();

    Console.WriteLine("{0} request was caught: {1}",
                       request.HttpMethod, request.Url);
                    
    response.StatusCode = (int)HttpStatusCode.OK;                    

    //Возвращаем ответ
    using (Stream stream = response.OutputStream){ }
}
Если запустить наш прослушиватель и отправить несколько запросов на локальный порт 9999, то можем получить следующий результат:
GET request was caught: http://127.0.0.1:9999/abra-kadabra
POST request was caught: http://127.0.0.1:9999/


Улучшение прослушивателя простой обработкой входящих сообщений
Теперь снабдим наш HttpListener простой обработкой входящих запросов. Предположим, что все запросы, которые отправлены методом POST и в строке запроса содержат /super-request/return-me-200/, будут возвращать статус код 200 OK. Запросы не подпадающие под наши требования будут возвращать 400 Bad Request.
Итак, приступим к внесению необходимых изменений. Создадим шаблон, который будем искать в строке запроса и добавим проверку входящих запросов:
...
string template = "/super-request/return-me-200/";
...
//Создаем ответ
if (request.HttpMethod == "POST" && request.RawUrl.Contains(template))
{
    string requestBody;
    Stream iStream = request.InputStream;
    ...
    Console.WriteLine("{0} request was caught: {1}",
                       request.HttpMethod, request.Url);
                   
    response.StatusCode = (int)HttpStatusCode.OK;                    

    //Return a response
    using (Stream stream = response.OutputStream){ }
}
else
{
    response.StatusCode = (int)HttpStatusCode.BadRequest;
    Console.WriteLine("Inappropriate request was caught: {0} {1}",
                       request.HttpMethod, request.Url);
    using (Stream stream = response.OutputStream) { }
}
Проверим новый функционал, отправив два HTTP запроса:
Запрос методом POST: http://127.0.0.1:9999/super-request/return-me-200/thanks
Реакция прослушивателя: POST request was caught: http://127.0.0.1:9999/super-request/return-me-200/thanks

Запрос методом PUT: http://127.0.0.1:9999/request/return-me-200/no
Реакция прослушивателя: Inappropriate request was caught: PUT http://127.0.0.1:9999/request/return-me-200/no


Вроде работает неплохо. Но наша цель использовать этот HttpListener в качестве утилиты в юнит тестахю. А запускать каждый раз консольное приложение перед запуском тестов не самый лучший вариант. Также результаты обработки запросов мы выводим в консоль, а хотим иметь возможность продолжать работать с этими результатами после обработки.
Я вижу следующие изменения, которые необходимо сделать:
- снабдить HttpListener методами запуска и остановки
- запускать HttpListener в фоне чтобы он не мешал запуску тестов в основном потоке
- добавить парочку событий (event) для возможности передачи результатов обработки наружу

Запуск и остановка HttpListener'а в фоне
Для использования в тестах было бы удобно запускать и останавливать HttpListener тогда, когда нам это действительно необходимо. Для этого вынесем запуск и остановку прослушивателя в отдельные методы, а также сделаем небольшие изменения в классе прослушивателя в целом:
public class MyHttpListener
{
    private string url;
    private string template;
    private bool isListening = false;
    private HttpListener listener = new HttpListener();
    private HttpListenerContext context;
    private HttpListenerRequest request;
    private HttpListenerResponse response;

    public MyHttpListener(Uri uri)
    {
        url = uri.AbsoluteUri;
        template = "/super-request/return-me-200/";        
    }

    public MyHttpListener(Uri uri, string template)
    {
        url = uri.AbsoluteUri;
        this.template = template;
    }

    public void StartListen()
    {
        listener.Prefixes.Add(url);
        listener.Start();

        isListening = true;
            
        while (isListening)
        {
            ...
        }
    }

    public void StopListen()
    {
        //возможно часть с флагом является лишней, но это добавляет спокойствия
        isListening = false; 
        listener.Stop();
        listener.Close();
    }
}
Теперь мы можем запустить наш HttpListener, но запустить тестовый сценарий, а в последствии остановить HttpListener уже не получится. Причина такого поведения кроется в том, что наш прослушиватель запустится в основном потоке и мы сразу столкнемся с "бесконечным" циклом while(isListening). Нужно срочно это исправить. Для этого работу прослушивателя вынесем в отдельный метод Start(), а процесс запуска в фоновом потоке оставим методу StartListen():
private Thread bgThread;

public void StartListen()
{
    bgThread = new Thread(new ThreadStart(Start));
    bgThread.IsBackground = true;
    bgThread.Name = "MyHttpListener";
    bgThread.Start();
}

private void Start()
{
    listener.Prefixes.Add(url);
    listener.Start();

    isListening = true;
            
    while (isListening)
    {
        ...
    }
}
Теперь можно запускать и останавливать HttpListener столько, сколько потребуется.

Получение рузльтатов обработки HTTP запросов
Все что нам осталось сделать - это добавить возможность продолжать работать с результатами обработки запросов в наших юнит тестах. Для этого создадим два события (для корректного и не очень запросов) и делегат для передачи результатов наружу:
public delegate void HttpListenerRequestHandler(object sender, MyHttpListenerEventArgs e);

public event HttpListenerRequestHandler OnWrongRequest;
public event HttpListenerRequestHandler OnCorrectRequest;

public class MyHttpListenerEventArgs : EventArgs
{
    public readonly HttpListenerResponse response;
    public MyHttpListenerEventArgs(HttpListenerResponse httpResponse)
    {
        response = httpResponse;
    }
}
И наконец, с помощью созданных событий, отправим результаты обработки наружу:
private void Start()
{
    listener.Prefixes.Add(url);
    listener.Start();

    isListening = true;
            
    while (isListening)
    {
        try
        {
            ...
            if (request.RawUrl.Contains(template) && request.HttpMethod == "POST")
            {
                ...
                if (OnCorrectRequest != null)
                    OnCorrectRequest(this, new MyHttpListenerEventArgs(response));
                ...
            }
            else
            {
                ...
                if (OnWrongRequest != null)
                    OnWrongRequest(this, new MyHttpListenerEventArgs(response));
                ...
            }
            ...
        }
        catch(HttpListenerException)
        {}
    }
}
Обработка HttpListenerException нужна для предотвращения ситуации, когда мы остановили прослушиватель в то время, как он ожидал входящий запрос или была попытка зарегистрировать префикс URI, который уже был зарегистрирован. Показанный вариант решения данной проблемы нельзя отнести к серии лучших практик, но для юнит тестов этого достаточно. В целом же альтернативы я пока еще не придумал.

Пример использования HttpListener
Напоследок небольшой пример использования нашего прослушивателя. Создадим обработчики событий и запустим простой тест:
[TestFixture]
class UnitTests
{
    private HttpListenerResponse listenerResponse;
    
    public void HttpCallbackRequestCorrect(object sender, MyHttpListenerEventArgs e)
    {
        listenerResponse = e.response;
        Console.WriteLine("{0} sent: {1}", sender, e.response.StatusCode);
    }

    public void HttpCallbackRequestWrong(object sender, MyHttpListenerEventArgs e)
    {
        listenerResponse = e.response;
        Console.WriteLine("{0} sent: {1}", sender, e.response.StatusCode);
    }

    [Test]
    public void SendCorrectRequestToHttpListener()
    {
        MyHttpListener.HttpListenerRequestHandler handlerForCorrectRequest =
            new MyHttpListener.HttpListenerRequestHandler(HttpCallbackRequestCorrect);
        MyHttpListener.HttpListenerRequestHandler handlerForWrongRequest =
            new MyHttpListener.HttpListenerRequestHandler(HttpCallbackRequestWrong);

        MyHttpListener httpListener = new MyHttpListener(new Uri("http://127.0.0.1:9999"));

        httpListener.OnCorrectRequest += handlerForCorrectRequest;
        httpListener.OnWrongRequest += handlerForWrongRequest;
        httpListener.StartListen();

        //Послать парочку HTTP запросов

        httpListener.OnCorrectRequest -= handlerForCorrectRequest;
        httpListener.OnWrongRequest -= handlerForWrongRequest;
        httpListener.StopListen();

        Assert.AreEqual(HttpStatusCode.OK.ToString(), listenerResponse.StatusDescription);
    }
}
Из примера видно, что возможностей для работы с полученными от прослушивателя данными неимоверное количество.

Заключение
В заключение хочу заметить, что данный HttpListener можно развивать и развивать. К примеру, можно возвращать не только HTTP статус код, но также какое-то сообщение в теле ответа, можно обрабатывать содержимое тела запроса, а во избежание создания нескольких, конфликтующих друг сдругом прослушивателей, можно реализовать HttpListener с использованием паттернов синглтон (singleton) или моностейт(monostate).

3 комментария:

  1. Отличная статейка, никакой IIS не нужен. А если ещё к данному прослушивателю заюзать файл hosts то можно вообще эмулировать обращение к внешним сайтам, например к http://www.microsoft.com, надо только у IIS 80 порт забрать (интерестно, можно ли это сделать). Ещё была бы какая нибудь хрень позволяющая подменить DNS резолвер (чтобы не файл hosts использовать, тогда бы работало во всех браузерах).

    ОтветитьУдалить
  2. Чтобы вернуть картину, нужно сначала создать html файл с картинкой и им отвечать.

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