Дата публикации статьи: 11.06.2004 14:59

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

 

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

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

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

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

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

  • Заключение

  • Исходный код

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

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

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

    Как правило, в системах "клиент-сервер" клиенты бывают различных типов. Например, одна программа позволяет проводить управление и администрирование, другая позволяет производить мониторинг, третья предназначена для основной работы.

    Контрольный запрос и ответ

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

    Аутентификационные данные пользователя

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

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

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

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

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

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

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

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

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

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

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

    Схема пакетов данных
    Расшифровка:
    <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. Другой способ -- переделать программный код приема/отправки данных, например, добавляя перед каждым блоком данных его длину в байтах и загружая требуемое число байт.

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

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

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

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

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

        Для сервера используется два сокета, 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
    

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

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

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

        Будем исходить из того, что на всех клиентских подсистемах есть форма 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).

    Заключение

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

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

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

    Исходный код

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

    Исходный код

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