Сообщений 2    Оценка 100 [+1/-0]         Оценить  
Система Orphus

Пишем ISAPI-расширение

Автор: Алексей Остапенко
NetInvest
Опубликовано: 01.04.2003
Исправлено: 13.03.2005
Версия текста: 1.2

А что это собственно такое?
А зачем это нужно?
Устройство типичного ISAPI-расширения.
Реализация GetExtensionVersion.
Реализация TerminateExtension.
Реализация HTTPExtensionProc.
Реализация ExtensionThreadProc.
Запись сообщений в Event Log NT.
Отладка ISAPI.
Заключение.

А что это собственно такое?

ISAPI или Internet Server Application Programming Interface - это набор интерфейсов, предоставляемых веб-сервером для написания приложений, взаимодействующих с IIS и расширяющих его возможности. Такие приложения могут принадлежать к двум типам - ISAPI-расширение и ISAPI-фильтр.

Фильтр непосредственно участвует в обработке пользовательского запроса с момента его получения сервером и до момента отправки ответа. Он может модифицировать запрос или ответ, изменить адресата, ответственного за обработку запроса, но он сам не является конечным получателем запроса.

Расширение, наоборот, является адресатом запроса и не может влиять на его параметры и путь обработки. ISAPI-расширения IIS - это альтернатива CGI-приложениям. Расширения (и фильтры) реализуются в виде dll. IIS загружает dll при первом запросе к расширению и выгружает ее либо при выгрузке веб-приложения (если включено кэширование ISAPI), либо после окончания обработки запроса (если кэширование выключено). ISAPI-расширение может быть вызвано как явно (путем запроса вида http://.../myisapi.dll?params), так и неявно (через карту расширений или при отображении на него запроса с помощью фильтра).

А зачем это нужно?

Зачем нужны ISAPI-расширения, если есть CGI, ASP и т.д.? Ответ - они быстрее и требуют меньших ресурсов. В отличии от CGI, ISAPI-раширения многопоточны, т.е. для обработки еще одного запроса не требуется загрузки еще одной копии приложения. По сравнению с ASP, они имеют гораздо больше возможностей (использование множества функций Win32 API без необходимости писать для этого COM-объекты) и существенно выигрывают по скорости, т.к. код уже откомпилирован и оптимизирован. Правда, наряду с преимуществами имеются и недостатки, такие как большая сложность программирования и отсутствие поддержки сессий. Тем не менее, ISAPI-расширения наиболее хорошо подходят для написания критичных по времени приложений для IIS.

Устройство типичного ISAPI-расширения.

Любое ISAPI-расширение должно экспортировать три обязательные функции:

Прототипы этих трех функций и все используемые в ISAPI структуры описаны в файле httpext.h, который мы будем подключать к своему проекту. Чтобы экспортировать эти функции, мы используем файл myisapi.def, добавив его в ресурсы проекта.

Реализация GetExtensionVersion.

Эта функция должна установить версию и описание ISAPI-расширения и вернуть TRUE. Если функция возвратит FALSE, расширение не будет загружено. Внутри GetExtensionVersion может быть проделана необходимая инициализация. Т.к. мы пишем прототип "серьезного" расширения, в нашем случае здесь будет происходить считывание параметров из реестра, подключение к логу сообщений Windows NT, инициализация семафора, создание порта завершения ввода/вывода (I/O completion port) и порождение нитей для пула.

В первом варианте (версии 1.0), я совершил тактическую промашку, создавая рабочие нити непосредственно перед началом обработки и завершая их в конце. Поскольку на создание нити тоже тратится какое-то время, при большом количестве запросов и коротком цикле обработки скорость работы приложения существенно снижается. В частности, новый вариант по моим тестам получился примерно на 25 процентов быстрее исходного (420 запросов в секунду против 340 в исходном варианте).

На этом проблемы, увы, не закончились. Ярослав Говорунов, в процессе нашей совместной работы над ISAPI_Rewrite 2.0, обнаружил грубое несоответствие реального процесса инициализации ISAPI-расширения процессу описанному в MSDN при отключенном кешировании ISAPI-расширений. MSDN утверждает, что функции GetExtensionVersion и TerminateExtension вызываются IIS'ом по одному разу (первая в самом начале, вторая - перед выгрузкой). На деле же оказалось, что в вышеупомянутых условиях вызов GetExtensionVersion и TerminateExtension выполняется для каждого потока IIS. Поэтому в версии 1.2 была добавлена синхронизация вызовов GetExtensionVersion и TerminateExtension с помощью глобальной критической секции.

Итак, из реестра будут считываться три параметра - количество нитей в пуле dwMaxThreads, время ожидания освобождения очереди dwQueueTimeout и максимальное количество запросов в очереди dwQueueSize. Семафор понадобится для отслеживания ограничения на количество запросов в очереди (в принципе, если не ожидается большого наплыва запросов, то от него можно спокойно избавиться). Здесь же будет создаваться основа пула нитей - порт завершения ввода/вывода, и сами нити, обслуживающие запросы. Вот как будет выглядеть код GetExtensionVersion:

BOOL WINAPI GetExtensionVersion(HSE_VERSION_INFO* pVer)
{
  //устанавливает версию
  pVer->dwExtensionVersion=MAKELONG(VERSION_MINOR, VERSION_MAJOR);
  //копируем описание
  memcpy(pVer->lpszExtensionDesc, szDescription, sizeof(szDescription));
  CComCritSecLock<CComAutoCriticalSection> lock(g_cs);
  if (g_lInitCount++ > 0)
      return g_bInitResult;
  HKEY hkey;
  //регистрируемся в логе
  if((hEvt = ::RegisterEventSource(NULL, EventSource)) == NULL)
  {
    //выводим отладочное сообщение
    ::OutputDebugString(TEXT("MyISAPI: Failed to register event source.\n"));
    return FALSE;
  }
  //открываем ключ реестра
  if(::RegOpenKeyEx(HKEY_LOCAL_MACHINE, REG_PATH, 0, KEY_READ, &hkey) == ERROR_SUCCESS)
  {
    DWORD dwSize = 4;
    DWORD dwType;
    //запрашиваем значение размера пула
    if(::RegQueryValueEx(hkey, REG_ISAPI_THREADS_POOL, NULL, &dwType, (unsigned char *)&dwMaxThreads, &dwSize)!=ERROR_SUCCESS || dwType!=REG_DWORD || !dwMaxThreads)
    {
      //инициализируем значениями по умолчанию
      dwMaxThreads = DefaultPool;
      dwSize = 4;
    }
    //запрашиваем значение таймаута
    if(::RegQueryValueEx(hkey, REG_ISAPI_QUEUE_TIMEOUT, NULL, &dwType, (unsigned char *)&dwQueueTimeout, &dwSize)!=ERROR_SUCCESS || dwType!=REG_DWORD || !dwQueueTimeout)
    {
      dwQueueTimeout = DefaultTimeout;
      dwSize = 4;
    }
    if(::RegQueryValueEx(hkey, REG_ISAPI_QUEUE_SIZE, NULL, &dwType, (unsigned char *)&dwQueueSize, &dwSize)!=ERROR_SUCCESS || dwType!=REG_DWORD || !dwQueueSize) dwQueueSize = DefaultSize;
    ::RegCloseKey(hkey);
  }
  //создаем IO Completion port
  if(hIOPort = ::CreateIoCompletionPort(INVALID_HANDLE_VALUE, NULL, 0, 0))
  {
    //создаем семафор
    if(hSemaphore = ::CreateSemaphore(NULL, dwQueueSize, dwQueueSize, NULL))
    {
      for(DWORD i=0; i < dwMaxThreads; i++)
        if(_beginthread(ExtensionThreadProc,0,NULL) == -1)
          ::ReportEvent(hEvt, EVENTLOG_ERROR_TYPE, 0, MSG_ISAPI_THREAD_FAILED, NULL, 0, 0, NULL, NULL); //не смогли создать нитку

      //запишем сообщение об успешной инициализации
      ::ReportEvent(hEvt, EVENTLOG_INFORMATION_TYPE, 0, MSG_ISAPI_INITIALIZED, NULL, 0, 0, NULL, NULL);
      g_bInitResult = TRUE;
      return TRUE;
    }
  }
  //запишем сообщение о сбое
  ::ReportEvent(hEvt, EVENTLOG_ERROR_TYPE, 0, MSG_ISAPI_INITIALIZATION_FAILED, NULL, 0, 0, NULL, NULL);
  return FALSE;
}
Обратите внимание, что создание новой нити осуществляется с помощью функции CRT _beginthread. Это необходимо, если внутри ExtensionThreadProc вы собираетесь использовать функции CRT. Если же функции CRT вам не нужны, вы можете заменить вызов _beginthread на CreateThread, только не забудьте затем освободить дескриптор нити (функция _endthread делает это автоматически, поэтому в этом примере не нужно беспокоиться об освобождении дескриптора).

Реализация TerminateExtension.

Функция TerminateExtension вызывается перед выгрузкой ISAPI-расширения из памяти. Этот вызов позволяет расширению освободить все используемые ресурсы. В нашем примере эта функция будет иметь следующий вид:

BOOL WINAPI TerminateExtension(DWORD dwFlags)
{
  CComCritSecLock<CComAutoCriticalSection> lock(g_cs);
  if (--g_lInitCount > 0)
      return TRUE;
  if(hIOPort)
      ::CloseHandle(hIOPort);
  //освобождаем семафор
  if(hSemaphore)
      ::CloseHandle(hSemaphore);
  //записываем сообщение об успешном завершении
  ::ReportEvent(hEvt,EVENTLOG_INFORMATION_TYPE,0,MSG_ISAPI_UNINITIALIZED,NULL,0,0,NULL,NULL);
  //отключаемся от лога
  ::DeregisterEventSource(hEvt);
  return TRUE;
}

Реализация HTTPExtensionProc.

Итак, мы подошли к одной из основных частей ISAPI-расширения. Именно эту функцию вызывает IIS для обработки запроса, передавая ей в качестве параметра указатель на структуру EXTENSION_CONTROL_BLOCK, описывающую контекст запроса. Если ваше расширение не занимается длительной обработкой запроса, то вы можете реализовать всю логику непосредственно внутри этой функции. Однако, если расширение производит длительные вычисления, работает с базой данных и т.п., то рекомендуется производить обработку запроса в отдельной нити. Это связано с тем, что пул нитей IIS, принимающих запросы, ограничен (по умолчанию 20 нитей). И если вы исчерпаете этот пул, ваш сервер будет неспособен начать обслуживание вновь поступающих запросов. Чтобы этого избежать, "большое" ISAPI-расширение создает свой собственный пул нитей, в которых обрабатываются запросы. В такой схеме, HTTPExtensionProc возвращает управление сразу после начала обработки запроса, сообщая IIS, что запрос продолжает обрабатываться, с помощью кода возврата HSE_STATUS_PENDING. Вот как выглядит возможная реализация HTTPExtensionProc с пулом ниток:

DWORD WINAPI HttpExtensionProc(LPEXTENSION_CONTROL_BLOCK lpECB)
{
  //ждем освобождения нити или наступления таймаута
  if(::WaitForSingleObject(hSemaphore,dwQueueTimeout) == WAIT_TIMEOUT)
  {
    //истек таймаут
    ::ReportEvent(hEvt, EVENTLOG_ERROR_TYPE, 0, MSG_ISAPI_QUEUE_TIMEOUT, NULL, 0, 0, NULL, NULL);
    return HSE_STATUS_ERROR;
  }
  //передаем указатель на Extension Control Block вместо указателя на overlapped
  if(!PostQueuedCompletionStatus(hIOPort,-1,NULL,(LPOVERLAPPED)lpECB))
  {
    ::ReleaseSemaphore(hSemaphore,1,NULL);
    return HSE_STATUS_ERROR;
  }
  //возвращаем статус отложенной обработки
  return HSE_STATUS_PENDING;
}

Пул нитей работает следующим образом: в GetExtensionVersion создается порт завершения ввода/вывода, управляющий потоками обработки. Каждый новый запрос ставится в очередь обработки (если не достигнут лимит размера очереди, отслеживаемый с помощью семафора) с помощью вызова PostQueuedCompletionStatus, при этом указатель на EXTENSION_CONTROL_BLOCK передается вместо указателя на структуру OVERLAPPED. Нити обработки ждут на порте завершения ввода/вывода посредством вызова GetQueuedCompletionStatus. I/O completion port регулирует выполнение рабочих потоков следующим образом: он позволяет параллельно выполняться не более чем заданному числу потоков (это число определяется последним параметром вызова CreateIoCompletionPort. В данном примере это число равно чилу процессоров в системе). Если в очереди порта есть сообщения, и число работающих (не ожидающих в GetQueuedCompletionStatus) потоков меньше максимального, то один из ждущих потоков освобождается для обработки сообщения.
ПРИМЕЧАНИЕ
Можно подумать, что в таком случае нет смысла создавать пул с количеством нитей большим, чем ограничение порта завершения ввода/вывода. Однако это не совсем так. Если связанная с портом нитка будет блокирована на каком-либо другом объекте (семафоре, мьютексе и т.п.), то вместо нее может быть освобождена другая нитка. Таким образом реальное число выполняющихся потоков может временно превышать установленное ограничение.

Реализовав пул нитей, мы переложили всю обработку запроса на функцию ExtensionThreadProc, которой мы и займемся далее.

Реализация ExtensionThreadProc.

Ну вот, в прошлом разделе мне удалось отвертеться от реализации функциональной части расширения. Теперь придется наконец за нее взяться. В качестве примера мы будем либо вычислять произведение параметров a и b запроса (если они присутствуют), либо возвращать все заголовки HTTP-запроса в том виде, в котором они дошли до расширения.

Сначала мы проанализируем строку запроса, которая содержится в поле lpszQueryString структуры EXTENSION_CONTROL_BLOCK на предмет наличия в ней параметров a и b. Если оба параметра присутствуют, то мы используем их произведение для формирования ответа. Если же хотя бы один параметр отсутствует, то мы используем функцию GetServerVariable, указатель на которую содержится в структуре EXTENSION_CONTROL_BLOCK для получения заголовков HTTP.

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

void ExtensionThreadProc(LPVOID)
{
  DWORD dwFlag, dwNull;
  OVERLAPPED * pParam;
  while(::GetQueuedCompletionStatus(hIOPort, &dwFlag, &dwNull, &pParam, INFINITE) && dwFlag != NULL)
  {
    //преобразуем указатель
    LPEXTENSION_CONTROL_BLOCK lpECB = (LPEXTENSION_CONTROL_BLOCK)pParam;
    //параметры a и b
    __int64 ia, ib;
    //флаги наличия парметров
    int iParamFlg = 0;
    //текущая позиция в строке запроса
    char *ppos = lpECB->lpszQueryString;
    char *pend = strchr(ppos, 0);
    ppos--;
    while(ppos && ppos + 3 < pend)
    {
      ppos++;
      char cPar = *ppos++;
      //ищем строчку вида "X="
      if(*ppos++ == '=')
      {
        switch(cPar)
        {
          case 'a':
              ia = atoi(ppos);
              iParamFlg |= 0x01;
              break;
          case 'b':
              ib = atoi(ppos);
              iParamFlg |= 0x02;
        }
      }
      //ищем следующий параметр.
      ppos = strchr(ppos, '&');
    }
    //указатель на буфер
    char *pAll = NULL;
    //размер буфера
    DWORD dwAll;
    if(iParamFlg == 3) //оба параметра
    {
      pAll = new char[21];
      __int64 res = ia*ib;
      sprintf(pAll, "%I64i", res);
      dwAll = strlen(pAll);
    }
    else
    {
      dwAll = 0;
      //определим требуемый размер буфера
      lpECB->GetServerVariable(lpECB->ConnID, "ALL_RAW", pAll, (unsigned long *)&dwAll); //get size
      pAll = new char[dwAll];
      //получим переменную
      lpECB->GetServerVariable(lpECB->ConnID, "ALL_RAW", pAll, (unsigned long *)&dwAll); //get data
    }
    char pszHdr[200];
    static char szHeader[] = "Content-Length: %lu\r\nContent-type: text/plain\r\nPragma: no-cache\r\nExpires: 0\r\nCache-Control: no-cache\r\n\r\n";
    //формируем заголовок
    sprintf(pszHdr, szHeader, dwAll); //create header
    HSE_SEND_HEADER_EX_INFO hInfo;
    hInfo.pszStatus = szRespOK;
    hInfo.pszHeader = pszHdr;
    hInfo.cchStatus = sizeof(szRespOK) - 1;
    hInfo.cchHeader = strlen(pszHdr);
    //используем KeepAlive http/1.1
    hInfo.fKeepConn = TRUE;
    //отправляем заголовок
    lpECB->ServerSupportFunction(lpECB->ConnID, HSE_REQ_SEND_RESPONSE_HEADER_EX, &hInfo, NULL, NULL);
    //отправляем данные
    lpECB->WriteClient(lpECB->ConnID, pAll, &dwAll, HSE_IO_SYNC);
    DWORD dwStatus = HSE_STATUS_SUCCESS_AND_KEEP_CONN;
    //уведомляем об успешном завершении обработки
    lpECB->ServerSupportFunction(lpECB->ConnID, HSE_REQ_DONE_WITH_SESSION, &dwStatus, NULL, NULL);
    delete [] pAll;
    //освобождаем семафор
    ::ReleaseSemaphore(hSemaphore, 1, NULL);
  }
  _endthread();
}

Как вы можете убедиться, ничего сильно сложного в написании ISAPI-расширений нет. Однако, если эта задача все же показалось вам сложной, вы можете писать свои ISAPI-расширения, используя MFC. Мастер проектов VC включает возможность создания каркаса ISAPI-расширений, использующих MFC-классы CHTTPServer и CHTTPServerContext, которые позволяют существенно упростить такие операции, как получение параметров из строки запроса. Однако, при этом сильно увеличивается объем кода и частично снижается производительность. Я сам начинал писать ISAPI-приложения, иcпользуя классы MFC. Написав один фильтр и три расширения, я решил для себя, что от MFC лучше отказаться.

Запись сообщений в Event Log NT.

В рассматриваемом примере используется лог сообщений NT. Данный раздел содержит описание того, как с ним работать. Если вас это не интересует, вы можете спокойно пропустить этот раздел и перейти к отладке.

Перед началом записи в лог приложение должно зарегистрировать источник событий. Это делается при помощи функции RegisterEventSource. В качестве первого параметра ей передается UNC-имя машины, к логу которой мы желаем обращаться (в нашем случае это локальный лог). Второй параметр - имя источника, которое предварительно должно быть зарегистрировано в реестре NT. Для регистрации источника событий в реестре нужно будет один раз запустить прилагаемый к проекту файл MyISAPI.reg. Реализация саморегистрации в данном случае нежелательна, т.к. у вашего расширения скорее всего не будет прав на запись в ключ HKEY_LOCAL_MACHINE\System\CurrentControlSet\Services\EventLog\Application\ реестра.

После успешной регистрации источника событий наше расширение может начать записывать в лог сообщения. Сообщения могут быть как простыми строками текста, так и заранее подготовленными сообщениями с параметрами. В данном примере используются сообщения, хранящиеся в отдельном модуле MyISAPIMsg.dll (именно он регистрируется в реестре). Для создания этого модуля нам потребуется написать файл, определяющий сообщения, и придется воспользоваться компилятором сообщений mc.exe. Файл сообщений MyISAPIMsg.mc будет иметь следующую вид:

LanguageNames=(Russian=0x419:MSG0419)
LanguageNames=(English=0x409:MSG0409)

MessageID=0x01
Severity=Informational
Facility=Application
SymbolicName=MSG_ISAPI_INITIALIZED
Language=English
ISAPI Extension Initialized.
.
Language=Russian
ISAPI-расширение загружено.
.

MessageID=0x02
Severity=Informational
Facility=Application
SymbolicName=MSG_ISAPI_UNINITIALIZED
Language=English
ISAPI Extension Uninitialized.
.
Language=Russian
ISAPI-расширение выгружено.
.

.....Часть текста опущена

MessageID=0x203
Severity=Error
Facility=Application
SymbolicName=MSG_ISAPI_THREAD_FAILED
Language=English
Thread creation failed.
.
Language=Russian
Ошибка при попытке создания новой нитки.
.

Как вы можете видеть, в начале файла идет описание языков сообщений. Затем идут собственно описания сообщений, причем каждое сообщение может содержать текст на нескольких языках.
ПРИМЕЧАНИЕ
Обратите внимание на точки после текстов сообщений - это не ошибка, это признак конца текста.
В этом примере все сообщения не имеют параметров, однако никто не мешает вам их добавить. Текст сообщения с параметрами имеет вид Part1 %1 part2 %2.... Если вам требуется более подробное описание формата файлов сообщений, обратитесь к документации по компилятору сообщений mc.exe.
ПРИМЕЧАНИЕ
Интересной особенностью компилятора сообщений mc.exe является то, что текстовые строки должны задаваться не в ANSI-кодировке, а в OEM-кодировке (например, cp866, а не cp1251 для русского языка).

Помимо файла сообщений нам потребуется файл-болванка для создания библиотеки MyISAPIMsg.c с таким содержимым:

#include <windows.h>
BOOL WINAPI DllMain(HINSTANCE hinstDLL,DWORD fdwReason,LPVOID lpvReserved);

Далее, удалите debug-конфигурацию из подпроекта MyISAPIMsg и добавьте следующие команды в Pre-link step:

mc MyISAPIMsg.mc
rc -r -fo Release\MyISAPIMsg.res MyISAPIMsg.rc

Затем, добавьте следующую строку к командной строке компоновщика:

Release\MyISAPIMsg.res /noentry

(флаг /noenty нужен для уменьшения размеров модуля, поскольку мы собираемся создать чисто ресурсную библиотеку).

Теперь соберите проект. Вы получите файл MyISAPIMsg.dll, содержащий нужные ресурсы. Перепишите этот файл в системный каталог NT (либо подправьте путь в reg-файле). Вместе с ресурсной библиотекой будет сгенерирован заголовочный файл MyISAPIMsg.h, который мы подключим к основному проекту.

Сейчас у нас есть все необходимое для записи сообщений в лог. Мы также можем собрать основной проект. И приступить к его отладке.

Отладка ISAPI.

Отладка ISAPI - процесс несколько более сложный, чем отладка обычного Win32-приложения. Это связано с тем, что у нас нет возможности запустить ISAPI-расширение отдельно от сервиса IIS, поэтому нам придется отлаживать сам сервис. В зависимости от того, как сконфигурировано ваше веб-приложение (In-process, pooled, isolated), может быть несколько вариантов подключения к нужному процессу. Я считаю, что самым простым являетcя следующий:

  1. вы переводите ваше веб-приложение в режим in-process;
  2. вы подключаетесь к процессу inetinfo;
  3. на закладке Project\Settings\Debug выбираете категорию Additional DLLs и добавляете туда ваш модуль MyISAPI.dll;
  4. теперь нужно открыть исходный текст модуля - файл MyISAPI.cpp и поставить точки останова;
  5. наконец, нужно сформировать запрос к модулю (например, из веб-браузера). По достижению первой точки останова, управление попадет в отладчик.

Существуют также и другие мощные средства отладки, которые можно применить для отлаживания ISAPI-приложений. Мне наиболее симпатичны два из них - Numega Bounds Checker, позволяющий в автоматическом режиме отлавливать большинство различного рода утечек, и Numega SoftIce -мощнейший низкоуровневый отладчик, в первую очередь предназначенный для отладки драйверов устройств, который, однако, позволяет с успехом отлаживать и высокоуровневые приложения, написанные на C. Тем не менее, рассмотрение этих средств не входит в рамки этой статьи.

Заключение.

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 2    Оценка 100 [+1/-0]         Оценить