Alibek Bolatov
 alibek@mail.ru
 version 1.0

Пример написания приложений "клиент-сервер"

В данной статье приводится пример написания приложения "клиент-сервер" с использованием компонента ActiveX WinSock.


Немного теории

Для начала, рассмотрим схему будущей системы "клиент-сервер".

    Упрощенная схема:
  1. Запускается серверная подсистема
  2. Запускается клиентская подсистема
  3. Клиент пытается подключиться к серверу
  4. Сервер проверяет подключение (например, по IP-адресу) и подключает клиента
  5. Сервер подтверждает подключение
  6. Клиент посылает идентификационную строку (если имеются клиенты различных типов)
  7. Сервер проверяет идентификационную строку и отправляет клиенту контрольный запрос (авторизация приложения)
  8. Клиент принимает контрольный запрос и сообщает ответ
  9. Сервер проверяет корректность ответа и авторизует приложение
  10. Клиент передает аутентификационные данные пользователя
  11. Сервер проверяет аутентификационные данные пользователя
  12. Если были пройдены все проверки, то соединение между клиентом и сервером установлено.
Пояснения:
Идентификационная строка
Как правило, в системах "клиент-сервер" клиенты бывают различных типов. Например, одна программа позволяет проводить управление и администрирование, другая позволяет производить мониторинг, третья предназначена для основной работы.
Контрольный запрос и ответ
Используется, чтобы предотвратить подключение в качестве клиентов неавторизованных (самодельно написанных) программ. Например, сервер отправляет тестовую строку, которую клиент должен преобразовать согласно некоторому алгоритму. Затем сервер сравнивает полученный результат с эталонным (который вычисляется на сервере) и если они совпадают, то авторизует приложение.
Аутентификационные данные пользователя
После авторизации приложения имеет смысл шифровать весь трафик между клиентом и сервером. Кроме того, лучше не передавать пароль пользователя по сети (даже по зашифрованному каналу), более предпочтительным будет передавать хэш пароля пользователя. После успешной авторизации пользователя сервер проверяет, имеет ли указанный пользователь право пользоваться данной клиентской программой, в противном случае соединение закрывается.

Чем шифровать и чем вычислять хэши - дело второстепенное, можно вообще отказаться от криптографии. Но в данном примере будет использовать хэши MD5 (ссылка 6) и шифрование по алгоритму "Энигма" (ссылка 5). И хеширование, и шифрование реализованно в классах, для использования своих алгоритмов достаточно заменить прилагаемые классы своими.

Более подробно информация о подключении и о структуре пакетов данных будет представлена в следующей главе.

Общее описание

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

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

Кроме того, имеется еще один ньюанс. Дело в том, что клиент не знает, когда сервер подготовит ответ и даже придет ли ответ вообще (т.к. данные принимаются и отправляются асинхронно). Кроме того, пользователю будет удобнее, если в то время, пока будут подготавливаться данные для первого запроса, он (пользователь) сможет отправить второй запрос.

В свете указанного встает проблема, как определить, на какой запрос пришел ответ от сервера. Самый простой путь (и на мой взгляд, оптимальный) - каждый запрос будет предваряться неким идентификатором (например, счетчиком), и когда сервер отправляет ответ клиенту, он добавляет к этому ответу ижентификатор запроса. Разумеется, на клиенте необходимо реализовать способ, чтобы по идентификатору запроса можно было определить сам запрос, т.е. на клиенте будет некий буфер или очередь, в которую будут добавляться элементы-запросы (при отправке запроса), а при получении ответа на запрос элементы будут удаляться из очереди.

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

Есть и еще один ньюанс. Шифровать трафик следует после авторизации приложения (не пользователя). Причин для этого несколько, самая очевидная -- такой механизм позволяет использовать динамический пароль и алгоритм шифрования. Другая причина, менее очевидная -- если достоверность клиента не подтверждена, то не следует ему (клиенту) знать о способах шифрования данных (при наличии большого количества зашифрованной информации вероятность ее расшифровки повышается).

На основании указанного была разработана следующая схема обмена данными.

Схема пакетов данных
Расшифровка:
<MsgID>
Идентификатор сообщения, Hex-dump, 8 байт.
<Data>
Данные (команда, текст запроса или любая другая информация), произвольной длины.
<Param>
Параметры, требуемые для запроса или команды. Поле необязательное, если указывается, то должно отделяться от данных символом табуляции (0x09 ASCII).
<RetCode>
Код возврата. В общем случае, код ошибки, возвращаемый сервером в ответ за запрос. В случае уведомлений является кодом уведомления. Hex-dump, 4 байта
<Tab>
Символ табуляции, разделитель полей между данными и параметрами. ASCII 0x09 (Dec 9).
<@>
Разделитель между идентификатором сообщения и кодом возврата. Символ "at", ASCII 0x40 (Dec 64).
кодирование
При шифровании передаваемых данных пакет данных кодируется следующим способом:
<Data>
Пакет данных (с идентификатором, кодом возврата, данными и пр.).
<Hash>
Хэш на пакет данных, Hex-dump, 32 байта (128 бит).
<EncodeData>
Зашифрованный пакет данных (хэш + пакет данных).
<HashEncode>
Хэш на зашифрованный пакет данных, Hex-dump, 32 байта (128 бит).
<0>
Разделитель между хэшем на зашифрованный пакет данных и самими зашифрованными данными. Символ "null", ASCII 0x00 (Dec 0).

Блоки данных, передаваемые между сервером и клиентом, разделяются двойным "null" (ASCII 0x0000); это требуется из-за неизбежной фрагментации пакетов при передаче их через WinSock. Вследствии того, что символ "null" имеет особое значение (разделитель полей), следует исключить вероятность появления данного символа внутри блока данных. Один из способов -- преобразовывать двоичные данные в Hex-dump. Другой способ -- переделать программный код приема/отправки данных, например, добавляя перед каждым блоком данных его длину в байтах и загружая требуемое число байт.

Такая схема позволяет использовать одну и ту же подпрограмму для обработки пакета данных в любом режиме (прием/ответ, зашифрованный канал/открытый канал). В статье код этой подпрограммы не приводится, т.к. сам по себе он ничего не дает (помимо этой подпрограммы используется еще несколько подпрограмм), их лучше смотреть непосредственно в примере (ссылка 1, ссылка 4).

Теперь о том, как реализован обмен данными. Клиент формирует запрос (предварительно назначив ему идентификатор), помещает его в очередь (обработка очереди должна быть реализована в клиенте). К этому запросу при необходимости добавляются параметры. Полученный результат упаковывается в строку, к этой строке добавляется хэш. Полученный блок данных шифруется и к зашифрованному блоку также добавляется хэш (подписывающий уже зашифрованный пакет данных). Этот хэш и сами зашифрованные данные разделяются символом "null", к концу блока добавляется двойной "null".

Сервер принимает пакеты данных и помещает их в буфер. Как только в буфере находится последовательность <NULL><NULL>, сервер извлекает пакет данных и удаляет его из буфера. Полученный пакет данных проверяется на валидность и расшифровывается. Затем сервер выполняет запрос, полученный от клиента, и передает клиенту идентификатор сообщения (чтобы клиент мог определить, на какой запрос пришел ответ), код возврата, уведомляющий о успешном или неуспешном выполнении запроса, и данные, полученные в случае успешного выполнения запроса (при неуспешном выполнении запроса вместо данных приходит текст ошибки). Эти данные также упаковываются в строку, шифруются и подписываются и отсылаются клиенту.

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

Реализация сервера

Ссылка 2. Для сервера используется два сокета, wsServer (который при запуске сервера переводится в режим LISTEN) и индексированный wsClients(0), к копиям которого будут подключаться клиенты. К клиентским сокетам дополнительно создается массив пользовательского типа, в котором будет фиксироваться дополнительная информация.

Private Enum ClientTypeEnum
  ctGeneral = 1
  ctOther2 = 2
  ...
End Enum

Private Enum ClientErrorCodes
  cerrSuccess = &H0&
  cerrServerBusy = &HFF&
  cerrAuth_WrongAppID = &H100&
  cerrAuth_WrongAppPassword = &H101&
  cerrAuth_WrongUserData = &H110&
  cerrAuth_WrongUserLocked = &H111&
  cerrAuth_WrongUserAccess = &H112&
  cerrGeneralError = &HFFFF&
End Enum

Private Const EventMsgID As String = "********"

Private Enum ClientConnectStates
  ccsNotConnect = 0
  ccsConnecting = 10
  ccsConnecting_WaitAppID = 11
  ccsConnecting_WrongAppID = 12
  ccsConnecting_SendPassword = 13
  ccsConnecting_ReceivePassword = 14
  ccsConnecting_WrongPassword = 15
  ccsConnecting_Complete = 19
  ccsAuthorizing = 20
  ccsAuthorizing_WaitUser = 21
  ccsAuthorizing_WrongUser = 22
  ccsAuthorizing_WrongAccess = 23
  ccsAuthorizing_Complete = 29
  ccsConnect = 30
  ccsDisconnecting = 90
End Enum

Private Type WinSockInfo
  ConnectClock As Date
  ConnectState As ClientConnectStates
  Crypto As Crypt
  Client As ClientTypeEnum
  User As String
  Data As String
  Buffer As String
End Type

Для сокета wsServer требуется такой код:

Private Sub wsServer_ConnectionRequest(ByVal requestID As Long)
MakeNewConnection requestID, wsServer.RemoteHostIP, wsServer.RemotePort
End Sub

Процедура MakeNewConnection должна делать следующее:

    MakeNewConnection
  1. Проверить, имеется ли возможность создать новый сокет
  2. Проверить RemoteHostIP и RemotePort (если требуется)
  3. Создать новый сокет и подключить к нему клиента
  4. Для нового сокета создать элемент WinSockInfo
  5. Перевести .ConnectState в состояние ccsConnecting_WaitAppID
Private Sub MakeNewConnection(ByVal requestID As Long, Optional ByVal RemoteHostIP As String, Optional ByVal RemotePort As Long)
Dim I As Long
I = GetFreeSocket()
If I = 0 Then
  wsClients(0).LocalPort = 0
  wsClients(0).Accept requestID
  ClientSend 0, EventMsgID, cerrServerBusy, "Server busy."
  Exit Sub
End If
ws(I).ConnectClock = Now()
ws(I).ConnectState = ccsConnecting
Set ws(I).Crypto = New Crypt
Load wsClients(I)
wsClients(I).Accept requestID
ws(I).ConnectState = ccsConnecting_WaitAppID
End Sub

Фактически, клиент уже подключен, но он не будет работать с сервером, пока не пройдет авторизацию. Для этого на клиентском сокете имеется такой код:

Private Sub wsClients_DataArrival(Index As Integer, ByVal bytesTotal As Long)
Dim I As Long, C As DataCheckResult, Data As String, MsgID As String, MsgBody As String, MsgParam As String
If Index = 0 Then Exit Sub
Data = Space$(bytesTotal)
wsClients(Index).GetData Data, vbString, bytesTotal
ws(Index).Buffer = ws(Index).Buffer & Data
Do
  I = InStr(ws(Index).Buffer, vbNullCharDbl)
  If I = 0 Then Exit Do
  Data = Left$(ws(Index).Buffer, I - 1)
  ws(Index).Buffer = Mid$(ws(Index).Buffer, I + Len(vbNullCharDbl))
  Select Case ws(Index).ConnectState
    Case ccsConnecting_WaitAppID
      C = ExtractDataString(Data, MsgID, MsgBody, MsgParam)
    Case ccsConnecting_ReceivePassword
      C = ExtractDataString(Data, MsgID, MsgBody, MsgParam)
    Case ccsAuthorizing_WaitUser
      C = ExtractDataString(Data, MsgID, MsgBody, MsgParam, ws(Index).Crypto)
    Case ccsConnect
      C = ExtractDataString(Data, MsgID, MsgBody, MsgParam, ws(Index).Crypto)
    Case Else
      C = -1
      MsgID = vbNullString
      MsgBody = vbNullString
      MsgParam = vbNullString
  End Select
  If C = dcrSuccess Then
    If ws(Index).ConnectState = ccsConnect Then
      ClientRequest Index, MsgID, MsgBody, MsgParam
    Else
      ClientAuth Index, MsgBody
    End If
  End If
Loop
End Sub

Авторизацией пользователя занимается процедура ClientAuth. Функция ExtractDataString принимает зашифрованные данные и расшифровывает их, заодно проверяя валидность. Код процедуры ClientAuth схематично показан ниже:

Private Sub ClientAuth(ByVal ClientIndex As Long, Message As String)
Dim S As String
Select Case ws(ClientIndex).ConnectState
  Case ccsNotConnect
  Case ccsConnecting_WaitAppID
    'В Message будет находится идентификатор типа клиента
    'В данном случае используется Select Case, но лучше использовать базу данных
    Select Case Message
      Case "WSCLIENT"
        If vServerOptions.IPFilterClient Then
          If Not CheckIPRange(wsClients(ClientIndex).RemoteHostIP, IPFilterClientList()) Then
            ws(ClientIndex).ConnectState = ccsConnecting_WrongAppID
            ws(ClientIndex).ConnectState = ccsDisconnecting
            ClientSend ClientIndex, EventMsgID, cerrAuth_WrongAppID, "IP-address in not valid range for this application."
            Exit Sub
          End If
        End If
        ws(ClientIndex).Client = ctGeneral
        ws(ClientIndex).ConnectState = ccsConnecting_SendPassword
        ws(ClientIndex).Data = GenerateKeyPhrase()
        ClientSend ClientIndex, EventMsgID, cerrSuccess, ws(ClientIndex).Data
        Set ws(ClientIndex).Crypto = New Crypt
        ws(ClientIndex).Crypto.KeyString = "wscs demo"
        ws(ClientIndex).ConnectState = ccsConnecting_ReceivePassword
      Case Else
        ws(ClientIndex).ConnectState = ccsConnecting_WrongAppID
        ws(ClientIndex).ConnectState = ccsDisconnecting
        ClientSend ClientIndex, EventMsgID, cerrAuth_WrongAppID, "Application not registered in the database."
    End Select
  Case ccsConnecting_ReceivePassword
    'Клиент должен преобразовать (зашифровать) полученную строку.
    'В Message находится хэш на преобразованную строку.
    S = ws(ClientIndex).Crypto.Encrypt(ws(ClientIndex).Data)
    If Message = md5.DigestStrToHexStr(S) Then
      ws(ClientIndex).ConnectState = ccsConnecting_Complete
      ws(ClientIndex).Crypto.KeyString = ws(ClientIndex).Data
      ws(ClientIndex).Data = vbNullString
      ClientSend ClientIndex, EventMsgID, cerrSuccess, ws(ClientIndex).Data
      Select Case ws(ClientIndex).Client
        Case ctGeneral
          ws(ClientIndex).ConnectState = ccsAuthorizing
          ws(ClientIndex).ConnectState = ccsAuthorizing_WaitUser
      End Select
    Else
      ws(ClientIndex).ConnectState = ccsConnecting_WrongPassword
      ws(ClientIndex).ConnectState = ccsDisconnecting
      ClientSend ClientIndex, EventMsgID, cerrAuth_WrongAppPassword, "Wrong control phrase."
    End If
  Case ccsAuthorizing_WaitUser
    'Авторизация приложения завершена, проводится авторизация пользователя
    'В Message находится аутентификационная информация вида AUTH: @
    ws(ClientIndex).User = vbNullString
    If Left$(Message, 6) = "AUTH: " Then
      S = Mid$(Message, 7)
      If InStrRev(S, "@") > 0 Then
        ws(ClientIndex).User = Left$(S, InStrRev(S, "@") - 1)
        ws(ClientIndex).Data = UCase$(Mid$(S, InStrRev(S, "@") + 1))
      End If
    End If
    If Len(ws(ClientIndex).User) = 0 Then
      ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserData, "Invalid user login/password.", ws(ClientIndex).Crypto
    Else
      'В данном случае используется Select Case, но, конечно, следует работать с БД
      Select Case ws(ClientIndex).User
        Case "wscs"
          S = md5.DigestStrToHexStr("admin")
          If S = ws(ClientIndex).Data Then
            ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserLocked, "User is locked.", ws(ClientIndex).Crypto
          Else
            ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserData, "Invalid user login/password.", ws(ClientIndex).Crypto
          End If
        Case "demo"
          S = md5.DigestStrToHexStr("demo")
          If S = ws(ClientIndex).Data Then
            If ws(ClientIndex).Client <> ctGeneral Then
              ws(ClientIndex).ConnectState = ccsAuthorizing_WrongAccess
              ws(ClientIndex).ConnectState = ccsDisconnecting
              ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserAccess, "Wrong user access.", ws(ClientIndex).Crypto
            Else
              ws(ClientIndex).ConnectState = ccsAuthorizing_Complete
              ClientSend ClientIndex, EventMsgID, cerrSuccess, "Access granted.", ws(ClientIndex).Crypto
              ws(ClientIndex).ConnectState = ccsConnect
            End If
          Else
            ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserData, "Invalid user login/password.", ws(ClientIndex).Crypto
          End If
        Case Else
          ClientSend ClientIndex, EventMsgID, cerrAuth_WrongUserData, "Invalid user login/password.", ws(ClientIndex).Crypto
      End Select
    End If
End Select
End Sub

Процедура ClientRequest получает все запросы всех клиентов.

Private Sub ClientRequest(ByVal ClientIndex As Long, MsgID As String, Data As String, Param As String)
Dim msg As String, N() As String, V() As String, I As Long
If ClientIndex = 0 Then Exit Sub
If ws(ClientIndex).ConnectState <> ccsConnect Then Exit Sub
'Check request syntax
'Check user access
'Check client access
'If Check = False Then
'  ClientSend ClientIndex, MsgID, cerrGeneralError, "General error", ws(ClientIndex).Crypto
'  Exit Sub
'End If
Call GetParams(Param, N(), V())
Select Case Data
  Case wsmcCommon_GetServerTime
    ClientSend ClientIndex, MsgID, cerrSuccess, ServerClock(), ws(ClientIndex).Crypto
  ...
  Case wsmcCommon_RegisterNotice
    msg = NoticeClientRegister(ClientIndex)
    If Len(msg) = 0 Then
      ClientSend ClientIndex, MsgID, cerrSuccess, , ws(ClientIndex).Crypto
    Else
      ClientSend ClientIndex, MsgID, cerrGeneralError, msg, ws(ClientIndex).Crypto
    End If
  Case wsmcCommon_UnRegisterNotice
    msg = NoticeClientUnregister(ClientIndex)
    If Len(msg) = 0 Then
      ClientSend ClientIndex, MsgID, cerrSuccess, , ws(ClientIndex).Crypto
    Else
      ClientSend ClientIndex, MsgID, cerrGeneralError, msg, ws(ClientIndex).Crypto
    End If
End Select
End Sub

Функция ClientSend используется для передачи данных клиенту. В ней реализуется шифрование и подписывание данных, после чего они отправляются на сокет.

Private Sub ClientSend(ByVal ClientIndex As Long, ByVal MsgID As String, ByVal Code As ClientErrorCodes, Optional ByVal Data As String, Optional Crypto As Crypt)
Dim msg As String
If ClientIndex > 0 Then
  If ws(ClientIndex).ConnectState = ccsNotConnect Then Exit Sub
End If
msg = Hex$(Code)
If Len(msg) < 4 Then msg = String$(4 - Len(msg), "0") & msg
If Len(Data) = 0 Then
  msg = MsgID & "@" & msg
Else
  msg = MsgID & "@" & msg & Data
End If
If Not (Crypto Is Nothing) Then
  msg = Crypto.Encrypt(md5.DigestStrToHexStr(msg) & msg)
  msg = md5.DigestStrToHexStr(msg) & vbNullChar & msg
End If
wsClients(ClientIndex).SendData msg & vbNullCharDbl
End Sub

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

Реализация клиента

Ссылка 3. Для клиента код выглядит проще. Кроме того, поскольку клиентской подсистеме требуется авторизоваться только однажды, то имеет смысл разделить авторизацию и передачу данных и реализовать авторизацию в одном модуле. Это упростит обновление авторизации в случае нескольких типов клиентов (они будут работать с одним и тем же модулем и достаточно будет просто перекомпилировать проект).

Будем исходить из того, что на всех клиентских подсистемах есть форма frmMAIN, на которой имеется сокет wsClient, через который и будет происходит прием и передача данных.

Ниже приводится упрощенный модуль Authorize.

Option Explicit

Public Enum ClientConnectStates
  ccsNotConnect = 0
  ccsConnecting = 10
  ccsConnecting_WaitAppID = 11
  ccsConnecting_WrongAppID = 12
  ccsConnecting_SendPassword = 13
  ccsConnecting_ReceivePassword = 14
  ccsConnecting_WrongPassword = 15
  ccsConnecting_Complete = 19
  ccsAuthorizing = 20
  ccsAuthorizing_WaitUser = 21
  ccsAuthorizing_WrongUser = 22
  ccsAuthorizing_Complete = 29
  ccsConnect = 30
  ccsDisconnecting = 90
End Enum
Public CurrentConnectState As ClientConnectStates

Private AuthBuffer As String

Public Const EventMsgID As String = "********"

Public Enum ClientErrorCodes
  cerrSuccess = &H0&
  cerrServerBusy = &HFF&
  cerrAuth_WrongAppID = &H100&
  cerrAuth_WrongAppPassword = &H101&
  cerrAuth_WrongUserData = &H110&
  cerrAuth_WrongUserLocked = &H111&
  cerrAuth_WrongUserAccess = &H112&
  cerrGeneralError = &HFFFF&
End Enum

Sub ClientAuth_Recv(ByVal Code As ClientErrorCodes, ByVal Message As String)
Dim C As String
C = Hex$(Code): If Len(C) < 4 Then C = String$(4 - Len(C), "0") & C
Select Case CurrentConnectState
  Case ccsConnecting
    Select Case Code
      Case cerrSuccess
        CurrentConnectState = ccsConnecting_WaitAppID
        ClientAuth_Send prj_ApplCode
      Case cerrServerBusy
        CurrentConnectState = ccsNotConnect
        MsgBox "Сервер перегружен"
      Case Else
        CurrentConnectState = ccsNotConnect
        MsgBox "При подключении произошла ошибка!"
    End Select
  Case ccsConnecting_WaitAppID
    Select Case Code
      Case cerrSuccess
        Crypt.KeyString = prj_ApplPassword
        AuthBuffer = Message
        CurrentConnectState = ccsConnecting_ReceivePassword
        ClientAuth_Send md5.DigestStrToHexStr(Crypt.Encrypt(Message))
      Case cerrAuth_WrongAppID
        CurrentConnectState = ccsNotConnect
        MsgBox "Приложение '" & prj_ProductNameEng & "' не зарегистрировано на сервере."
      Case Else
        CurrentConnectState = ccsNotConnect
        MsgBox "При подключении произошла ошибка!"
    End Select
  Case ccsConnecting_ReceivePassword
    Select Case Code
      Case cerrSuccess
        Crypt.KeyString = AuthBuffer
        CurrentConnectState = ccsAuthorizing
        Call ClientAuth_Logon
      Case cerrAuth_WrongAppPassword
        CurrentConnectState = ccsNotConnect
        MsgBox "Невозможно зарегистрировать приложение"
      Case Else
        CurrentConnectState = ccsNotConnect
        MsgBox "При подключении произошла ошибка!"
    End Select
  Case ccsAuthorizing
  Case ccsAuthorizing_WaitUser
    Select Case Code
      Case cerrSuccess
        CurrentConnectState = ccsConnect
      Case cerrAuth_WrongUserData
        MsgBox "Невозможно войти в систему, неверные учетные данные."
        Call ClientAuth_Logon
      Case cerrAuth_WrongUserLocked
        CurrentConnectState = ccsNotConnect
        MsgBox "Невозможно войти в систему, учетная запись заблокированна."
      Case cerrAuth_WrongUserAccess
        CurrentConnectState = ccsNotConnect
        MsgBox "Невозможно войти в систему, доступ к подсистеме не разрешен."
      Case Else
        CurrentConnectState = ccsNotConnect
        MsgBox "При подключении произошла ошибка!"
    End Select
End Select
End Sub

Sub ClientAuth_Send(ByVal Message As String)
If CurrentConnectState = ccsNotConnect Then Exit Sub
Message = EventMsgID & Message
If CurrentConnectState > ccsConnecting_Complete Then
  Message = Crypt.Encrypt(md5.DigestStrToHexStr(Message) & Message)
  Message = md5.DigestStrToHexStr(Message) & vbNullChar & Message
End If
frmMAIN.wsClient.SendData Message & vbNullCharDbl
End Sub

Sub ClientAuth_Logon()
'Здесь отображается диалоговое окно, в котором пользователь вводит логин и пароль.
'После ввода данных на сервер отсылается строка вида: AUTH: <LOGIN>@<PWDHASH>
'где <LOGIN> - логин, а <PWDHASH> - хэш на пароль.
CurrentConnectState = ccsAuthorizing_WaitUser
ClientAuth_Send "AUTH: " & LOGIN & "@" & PWDHASH
End Sub

Для сокета имеется такой код:

Private Sub wsClient_DataArrival(ByVal bytesTotal As Long)
Dim I As Long, msg As String, MsgID As String, MsgCode As ClientErrorCodes, MsgBody As String
msg = Space$(bytesTotal)
wsClient.GetData msg, vbString, bytesTotal
MsgBuff = MsgBuff & msg
Do
  I = InStr(MsgBuff, vbNullCharDbl)
  If I = 0 Then Exit Do
  msg = Left$(MsgBuff, I - 1)
  MsgBuff = Mid$(MsgBuff, I + Len(vbNullCharDbl))
  If CurrentConnectState = ccsConnect Then
    If ExtractDataString(msg, MsgID, MsgCode, MsgBody, Crypt) = dcrSuccess Then
      If MsgID = EventMsgID Then
        WinSock_Event (MsgCode), MsgBody
      Else
        WinSock_Processing MsgID, MsgCode, MsgBody
      End If
    End If
  Else
    If ExtractDataString(msg, MsgID, MsgCode, MsgBody) = dcrSuccess Then
      ClientAuth_Recv MsgCode, MsgBody
    End If
  End If
Loop
End Sub

Функция ClientAuth_Recv вызывается в процессе авторизации, функции WinSock_Event и WinSock_Processing вызываются при получении уведомлений и ответов соответственно. Сами эти функции не приводятся, т.к. их содержимое будет зависеть от требований к подсистеме. Процедура WinSock_Event получает код уведомления и данные. Процедура WinSock_Processing получает идентификатор сообщений (по которому можно будет определить, каков был запрос), код возврата и содержимое сообщения.

Сложности и проблемы

Большинство проблем связаны с клиентской подсистемой. На серверной части сложность только в одном -- обеспечить асинхронность обработки запросов от разных клиентов. Желательно было бы реализовать асинхронность даже на уровне запросов для одного клиента, т.е. чтобы два разных запроса от одного и того же клиента обрабатывались независимо друг от друга. Одно из решений в подобных случаях -- создавать потоки для обработки каждого запроса.

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

С клиентом же дело обстоит сложнее, приходится реализовывать (в самом клиенте) очередь сообщений и усложнять код. В прилагаемом примере реализован один из вариантов организации очереди; на современных машинах он работает достаточно быстро.

Еще один момент, который следовало бы отметить -- передача больших объемов данных. Данная реализация клиент-серверной платформы мало подходит для постоянной передачи данных объемом свыше 10-15 Кб. Тем не менее, нет никаких ограничений на передачу данных, которые могут уместиться в тип данных VB String (около двух гигобайт). Для передачи данных, объем которых превышает 32 Кб нужно будет переделать класс MD5Hash; в данном классе длина текста запоминается в Integer, которое не может принимать значения, выходящие за пределы -32767...+32767. По всем вопросам, связанным с исправлением класса MD5Hash следует обращаться к его автору, Robert M. Hubley (e-mail).

Заключение

Данная статья была подготовлена для журнала VBStreets.

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

Использованный пример не имеет никакого практического значения, он предназначен только для демонстрации и пояснения. Вы можете использовать его в качестве основы для своих клиент-серверных систем. Кроме того, в примере использована библиотека функций (модули modCommon.bas и modWinAPI.bas), которые могут пригодится в разработке своих программ.

Как запустить проект. Запустить сервер (wscs_s.exe), стартовать сервер. Запустить клиент (wscs_c.exe). На сервере созданы два пользователя, demo и wscs (пароли совпадают с логином), второй пользователь заблокирован (т.е. для входа использовать demo/demo). На сервере можно вызвать окно журнала, чтобы видеть протокол авторизации.


Ссылки:

Все ссылки представляют собой ZIP-архив, на архив установлен пароль "alibek09.narod.ru".

1. Готовый пример (исходник, zip).

2. Серверная часть (исходник, zip).

3. Клиентская часть (исходник, zip).

4. Демонстрация (exe, zip).

5. Класс Crypt (исходник, zip).

6. Класс MD5Hash (исходник, zip).


Материалы данной статьи не допускается размещать без указания на данную страницу.

Дата: 8 июня 2004 г.
e-mail

Эту статью читали раз(а)

Hosted by uCoz