Программирование в сетях
Моя последняя прога 2005-го года - многопоточный клиент-серверный Messenger. Эта прога основана на нескольких довольно хитрых технологиях:
- Cвязь NET-COM
- Remoting-программирование в сетях
- Мультипоточное программирование
- Маршализация данных в поток формы
- Tриггеры SQL-сервера вызывают события у удаленной клиентской VB6-проги.
Здесь будут описаны и выложены реально работающие проекты, расширяя которые, можно легко двигатся в любом направлении дальше. Реальная же система с применением этих технологий СУЩЕСТВЕННО сложнее - хотя бы потому что демонстрационный SQL-скрипт (выложенный ниже) разбивается на множество триггеров, сложным образом срабатывающих от действий клиентов - действия которых (опять же) зависят как от работы этих триггеров, так и от обработки событий, передаваемых с SQL-сервера по TCP/IP.
Ну и естественно, в реальном приложении нет никаких диагностических сообщений с SQL-сервера, наглухо его подвешивающих, и нет никаких конфигураций C:\Program Files\Microsoft SQL Server\MSSQL\Binn\sqlservr.exe.config - все ведется в таблах.
Ну и еще самое главное отличие реальной системы от рассмотренной ниже - SQL-сервер не передает сообщения триггеров не непосредственно приложению (т.к. приложение может быть за брендмауэром), а передает промежуточному прокcи-серверу, который проксирует пакеты из внутреннего во внешний интерфейс).
Для начала посмотрим как клиентское приложение на VB6 слушает сообщения с SQL-сервера (минуя собственно коннект к SQL-серверу). Для этого делается вот такое демонстрационное приложение на VB6:
00001: Option Explicit 00002: Dim WithEvents Net As ComComponent.NET_Listener 00003: ' 00004: Private Sub Form_Load() 00005: On Error GoTo Msg1 00006: Set Net = New ComComponent.NET_Listener 00007: Exit Sub 00008: Msg1: MsgBox ("Ошибка создания обьекта прослушки сети" & vbCrLf & Err.Description) 00009: End Sub 00010: Private Sub Command1_Click() 00011: On Error GoTo Msg2 00012: Net.Hello ("Привет из Net!") 00013: Exit Sub 00014: Msg2: MsgBox ("Ошибка создания обьекта прослушки сети. " & vbCrLf & Err.Description) 00015: End Sub 00016: Private Sub Command2_Click() 00017: On Error GoTo Msg3 00018: Call Net.Start 00019: Exit Sub 00020: Msg3: MsgBox ("Ошибка начала прослушики сети." & vbCrLf & Err.Description) 00021: End Sub 00022: Private Sub Command3_Click() 00023: On Error GoTo Msg4 00024: Call Net.Abort 00025: Exit Sub 00026: Msg4: MsgBox ("Ошибка остановки прослушки сети." & vbCrLf & Err.Description) 00027: End Sub 00028: Private Sub Net_MyEvent(ByVal sender As Variant, ByVal e As mscorlib.EventArgs) 00029: Text1 = sender 00030: End Sub
Как видите в этом приложении в строке 6 создается экземпляр проги ComComponent.NET_Listener, которая и будет прослушивать сеть минуя основной поток VB6 и когда из сети будут поступать сообщения - ComComponent.NET_Listener будет вызывать в строке 28 событие Net_MyEvent и эта прога будет показывать у себя на форме то, что ComComponent.NET_Listener принял из сети. Теперь собственно прога NET_LISTENER:
00001: Imports System.Net 00002: Imports System.Net.Sockets 00003: Imports System.IO 00004: 00005: <ComClass()> _ 00006: Public Class NET_Listener 00007: Public Event MyEvent As EventHandler 00008: Dim ValidIP As New System.Text.RegularExpressions.Regex("[1-2]\d{1,2}\.[1-2]?\d{1,2}\.[1-2]?\d{1,2}\.[1-2]?\d{1,2}") 00009: Dim NewThread As Threading.Thread 00010: Dim ClientIP As System.Net.IPAddress 00011: Dim Listener As System.Net.Sockets.TcpListener 00012: Dim BinReader As System.IO.BinaryReader 00013: Dim TCPClient As System.Net.Sockets.TcpClient 00014: Dim NetStream As System.Net.Sockets.NetworkStream 00015: Dim Config As System.Collections.Specialized.NameValueCollection 00016: Dim Result As Int32 00017: 00018: Public Sub New() 00019: MyBase.new() 00020: End Sub 00021: 00022: Public Function Hello(ByVal b As String) 00023: MsgBox(b & vbCrLf & "Поток : " & AppDomain.GetCurrentThreadId.ToString & vbCrLf & _ 00024: "Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString) 00025: End Function 00026: 00027: Public Function Start() 00028: If NewThread Is Nothing Then 'нам лишних потоков не надо 00029: Config = System.Configuration.ConfigurationSettings.AppSettings 00030: If Config.Count = 0 Then 00031: MsgBox("Ошибка. Нет файла конфигурации " & vbCrLf & _ 00032: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00033: "в каталоге " & vbCrLf & _ 00034: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase) 00035: Else 00036: If Len(Config("IP")) = 0 Or Len(Config("PORT")) = 0 Then 00037: MsgBox("Ошибка. В файле конфигурации " & vbCrLf & _ 00038: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00039: "в каталоге " & vbCrLf & _ 00040: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase & vbCrLf & _ 00041: "Нет ключей - ""IP"" или ""PORT"" ") 00042: Else 00043: If ValidIP.IsMatch(Config("IP")) And Val(Config("PORT")) < 65535 Then 00044: NewThread = New Threading.Thread(AddressOf TCP_Listener) 00045: NewThread.Start() 00046: Else 00047: MsgBox("Ошибка. В файле конфигурации " & vbCrLf & _ 00048: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00049: "в каталоге " & vbCrLf & _ 00050: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase & vbCrLf & _ 00051: "Неверно заданы ключи - ""IP"" или ""PORT"" ") 00052: End If 00053: End If 00054: End If 00055: End If 00056: End Function 00057: 00058: Public Sub Abort() 00059: TCPClient.Close() 00060: Listener.Stop() 00061: 'динамические обьекты из стека - остальные все вроде статические 00062: Listener = Nothing 00063: BinReader = Nothing 00064: TCPClient = Nothing 00065: ' 00066: NewThread.Abort() 00067: NewThread = Nothing 00068: End Sub 00069: 00070: Private Sub TCP_Listener() 00071: Try 00072: ClientIP = IPAddress.Parse(Config("IP")) 00073: Listener = New System.Net.Sockets.TcpListener(ClientIP, Val(Config("PORT"))) 00074: 00075: Listener.Start() 00076: ' 00077: 'Теперь либо ждем вручную по Pending либо останавливаемся до получения пакета по AcceptSocket или AcceptTcpClient 00078: 'System.Windows.Forms.Application.DoEvents() 'можно добавить чтобы не подвисали формы 00079: ' 00080: TCPClient = Listener.AcceptTcpClient() 00081: ' 00082: 'Здесь поток ЖДЕТ первого пакета из сети чтобы все проинициализировалось 00083: ' 00084: NetStream = TCPClient.GetStream 00085: ' 00086: 'Иначе можно работать прямо по сырым сокетам без потоков 00087: 'Dim MySocket As Socket = Listener.AcceptSocket() 00088: 'MySocket.Receive(....) Encoding... и т.д. 00089: ' 00090: BinReader = New System.IO.BinaryReader(NetStream) 00091: ' 00092: Do While True 00093: ' 00094: Result = BinReader.ReadInt32 'пока данных нет - поток в этом месте подвисает 00095: ' 00096: 'MsgBox("Привет из потока " & AppDomain.GetCurrentThreadId.ToString & vbCrLf & _ 00097: '"Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString & vbCrLf & _ 00098: '"Из сети получено - " & Reader.ReadUInt32.ToString & vbCrLf & _ 00099: '"Press any key чтобы передать событие в VB") 00100: ' 00101: RaiseEvent MyEvent(Result, System.EventArgs.Empty) 00102: ' 00103: Loop 00104: 00105: Catch x As System.Exception 00106: If TypeName(x) = "ThreadAbortException" Then 00107: 'сообщений при закрытии потока нам не надо 00108: Else 00109: MsgBox("Ошибка TCP/IP" & vbCrLf & x.Message & vbCrLf & _ 00110: "Поток : " & AppDomain.GetCurrentThreadId.ToString & vbCrLf & _ 00111: "Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString) 00112: End If 00113: End Try 00114: 00115: End Sub
Как видите в строке 5 - это класс на NET эмулирует COM (только не забудьте поставить соответсвующую галку в свойствах проекта, чтобы .NET-студия сама регистрировала классы в реестре) - что и позволяет прицепить эту прогу к VB6. Строки 23-24 полволяют увидеть номер основного потока VB6 и убедится что он STA, в отличие от потока TCP_Listener, создаваемого в строке 45 (который имеет MTA - Thread Model). Далше управление возвращается в VB6 - а здесь остается Listener, создается указатель на поток данные из сети и Reader (в строке 94) читает данные - пока не прервется коннект и вызывает события в VB6.
Большая часть текста (29-51) тут посвящена загрузки конфигурации из внешнего файла и разбору заданных там параметров с помощью регулярных выражений. Понятно что эти параметры будут вводит какой-нибудь менеджер, думающий только о своих процентах, и органически не способный думать ни о чем другом, кроме денег. К тому же хитрость в том, что конфигурационный файл имеет имя в зависимости не от имени NET-класса, а от имени EXE-файла приложения на VB6, что тоже вносит свою путаницу. Поэтому столько кода уделено конфигурационному файлу. Полный текст этого проекта можно сгрузить отсюда.
Разумеется вышеприведенный способ получить нужный адрес сетевого интерфейса - не единственный. Просто я принял такое решение в этой своей проге. По крайней мере есть еще два способа указать проге сетевой интерфейс, о которых можно узнать здесь. А также собственно говоря непосредственное получение адреса так:
Теперь рассмотрим сторону SQL-сервера. Я написал достаточно универсальное приложение (которое в публикуемом варианте) скорее более виндузовое, чем SQL-серверное расширение. Оно состоит из двух проектов. Один проект - библиотека передачи сообщений в сеть, другой вот такая форма для тестирования первого проекта, с вот таким текстом:
00001: Public Class Form1 00002: Inherits System.Windows.Forms.Form 00003: Dim Sender1 As TCP_SENDER1.NET_Sender 00004: Dim I As Int32 00005: 00006: #Region " Windows Form Designer generated code " 00007: #End Region 00008: 00009: Private Sub btStart_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles btStart.Click 00010: Sender1 = New TCP_SENDER1.NET_Sender() 00011: Sender1.Hello("Тест TCP/IP передатчика.") 00012: If Sender1.Start() > 0 Then 00013: MsgBox("Не получилось инициализировать передатчик.") 00014: Else 00015: btConnect.Enabled = True 00016: End If 00017: End Sub 00018: 00019: Private Sub btConnect_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles btConnect.Click 00020: If Sender1.TCP_Connect() > 0 Then 00021: MsgBox("Не получилось приконнектится.") 00022: Else 00023: btStart.Enabled = False 00024: btWrite.Enabled = True 00025: btConnect.Enabled = False 00026: btStop.Enabled = True 00027: End If 00028: End Sub 00029: 00030: Private Sub btWrite_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles btWrite.Click 00031: I += 1 00032: TextBox1.Text = I 00033: If Sender1.TCP_Write(I) > 0 Then 00034: MsgBox("Не получилось передать данные.") 00035: End If 00036: End Sub 00037: 00038: Private Sub btStop_Click(ByVal sender As Object, ByVal e As System.EventArgs) Handles btStop.Click 00039: Sender1.Abort() 00040: btStart.Enabled = True 00041: btStop.Enabled = False 00042: btConnect.Enabled = False 00043: btWrite.Enabled = False 00044: End Sub 00045: End Class
Как видите - это просто вызовы методов TCP-передатчика по кнопкам формы. У TCP-передатчика есть и настроечный модуль, описывающий конечную точку TCP/IP - т.е. в точности ту же точку, что описывал предыдущий конфигурационный файл. Локальный порт TCP/IP выбирается передатчиком по умолчанию случайным образом - что в большинстве случаев не требует изменения. Теперь собственно текст NET_Sender:
00001: Imports System.Net 00002: Imports System.Net.Sockets 00003: Imports System.IO 00004: 00005: 00006: <ComClass()> _ 00007: Public Class NET_Sender 00008: Public Event MyEvent As EventHandler 00009: Dim ValidIP As New System.Text.RegularExpressions.Regex("[1-2]\d{1,2}\.[1-2]?\d{1,2}\.[1-2]?\d{1,2}\.[1-2]?\d{1,2}") 00010: Dim ClientIP As System.Net.IPAddress 00011: Dim TcpClient As System.Net.Sockets.TcpClient 00012: Dim NetWriter As System.IO.BinaryWriter 00013: Dim Config As System.Collections.Specialized.NameValueCollection 00014: 00015: Public Sub New() 00016: MyBase.new() 00017: End Sub 00018: 00019: Public Function Hello(Optional ByVal b As String = "") 00020: System.Windows.Forms.MessageBox.Show(b & vbCrLf & "Поток : " & AppDomain.GetCurrentThreadId.ToString & _ 00021: ". Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString, "NET_Sender", Windows.Forms.MessageBoxButtons.OK, Windows.Forms.MessageBoxIcon.Information, Windows.Forms.MessageBoxDefaultButton.Button1, Windows.Forms.MessageBoxOptions.ServiceNotification) 00022: End Function 00023: 00024: Public Function Start() 00025: Config = System.Configuration.ConfigurationSettings.AppSettings 00026: If Config.Count = 0 Then 00027: Try 00028: System.Windows.Forms.MessageBox.Show("Ошибка. Нет файла конфигурации " & vbCrLf & _ 00029: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00030: "в каталоге " & vbCrLf & _ 00031: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase _ 00032: , "NET_Sender", Windows.Forms.MessageBoxButtons.OK, Windows.Forms.MessageBoxIcon.Information, Windows.Forms.MessageBoxDefaultButton.Button1, Windows.Forms.MessageBoxOptions.ServiceNotification) 00033: Catch x As System.Exception 00034: Start = 1 00035: End Try 00036: Else 00037: If Len(Config("IP")) = 0 Or Len(Config("PORT")) = 0 Then 00038: Try 00039: System.Windows.Forms.MessageBox.Show("Ошибка. В файле конфигурации " & vbCrLf & _ 00040: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00041: "в каталоге " & vbCrLf & _ 00042: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase & vbCrLf & _ 00043: "Нет ключей - ""IP"" или ""PORT"" " _ 00044: , "NET_Sender", Windows.Forms.MessageBoxButtons.OK, Windows.Forms.MessageBoxIcon.Information, Windows.Forms.MessageBoxDefaultButton.Button1, Windows.Forms.MessageBoxOptions.ServiceNotification) 00045: Catch x As System.Exception 00046: Start = 2 00047: End Try 00048: Else 00049: If ValidIP.IsMatch(Config("IP")) And Val(Config("PORT")) < 65535 Then 00050: Try 00051: TcpClient = New System.Net.Sockets.TcpClient() 00052: ClientIP = IPAddress.Parse(Config("IP")) 00053: Start = 0 00054: Catch x As System.Exception 00055: Start = 4 00056: End Try 00057: Else 00058: Try 00059: System.Windows.Forms.MessageBox.Show("Ошибка. В файле конфигурации " & vbCrLf & _ 00060: System.AppDomain.CurrentDomain.SetupInformation.ConfigurationFile & vbCrLf & _ 00061: "в каталоге " & vbCrLf & _ 00062: System.AppDomain.CurrentDomain.SetupInformation.ApplicationBase & vbCrLf & _ 00063: "Неверно заданы ключи - ""IP"" или ""PORT"" " _ 00064: , "NET_Sender", Windows.Forms.MessageBoxButtons.OK, Windows.Forms.MessageBoxIcon.Information, Windows.Forms.MessageBoxDefaultButton.Button1, Windows.Forms.MessageBoxOptions.ServiceNotification) 00065: Catch x As System.Exception 00066: Start = 3 00067: End Try 00068: End If 00069: End If 00070: End If 00071: End Function 00072: 00073: Public Function TCP_Connect() 00074: If TcpClient Is Nothing Then 00075: 'Старта еще не было 00076: TCP_Connect = 1 00077: Else 00078: Try 00079: TcpClient.Connect(System.Net.IPAddress.Parse(Config("IP")), Val(Config("PORT"))) 00080: Dim NetStream = TcpClient.GetStream 'указатель потока в сети 00081: NetWriter = New System.IO.BinaryWriter(NetStream) 'обьект, который пишет в поток 00082: TCP_Connect = 0 00083: Catch x As System.Exception 00084: System.Windows.Forms.MessageBox.Show("Ошибка коннекта в порт TCP/IP " & Config("IP") & ":" & Config("Port") & vbCrLf & _ 00085: x.Message & vbCrLf & _ 00086: "Поток : " & AppDomain.GetCurrentThreadId.ToString & _ 00087: ". Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString _ 00088: , "NET_Sender", Windows.Forms.MessageBoxButtons.OK, Windows.Forms.MessageBoxIcon.Information, Windows.Forms.MessageBoxDefaultButton.Button1, Windows.Forms.MessageBoxOptions.ServiceNotification) 00089: TCP_Connect = 1 00090: End Try 00091: End If 00092: End Function 00093: 00094: Public Function TCP_Write(Optional ByVal Value As Int32 = 0) 00095: If NetWriter Is Nothing Then 00096: 'Коннекта еще не было 00097: TCP_Write = 1 00098: Else 00099: 00100: Try 00101: NetWriter.Write(Value) 00102: TCP_Write = 0 00103: Catch x As System.Exception 00104: MsgBox("Ошибка записи в порт TCP/IP " & Config("IP") & ":" & Config("Port") & vbCrLf & _ 00105: x.Message & vbCrLf & _ 00106: "Поток : " & AppDomain.GetCurrentThreadId.ToString & _ 00107: ". Thread Apartment State: " & System.Threading.Thread.CurrentThread.ApartmentState.ToString) 00108: TCP_Write = 1 00109: End Try 00110: End If 00111: End Function 00112: 00113: Public Sub Abort() 00114: If TcpClient Is Nothing Then 00115: Else 00116: TcpClient.Close() 00117: TcpClient = Nothing 00118: End If 00119: If NetWriter Is Nothing Then 00120: Else 00121: NetWriter.Close() 00122: NetWriter = Nothing 00123: End If 00124: End Sub 00125: End Class
Как видите - тут тот же принцип. Грузится конфигурационный файл - разбирается по регулярным выражениям. Создается экземпляр TCP-клиента (указатель на который давал TCP-Listener в серверном варианте) - TcpClient.GetStream дает указатель на поток в сетит (строка 80), строка 81 - создаем BinaryWriter в этом потоке, и пишем в сеть пока не надоест - строка 101. Полный текст проекта можно сгрузить отсюда.
Естественно, если надо - можно писать не только целое число - как в этом примере и так далее - проект можно расширять в любую сторону. Ну а мне надо было расширять дальше в сторону вызова этого NET_Sender из триггеров SQL-Сервера. Это уже конкретная специфика проекта, которая тут описана не будет. Вот лишь пару картинок - как это можно вызывать из SQL-сервера. Верхний рисунок - это просто контролька срабатывания триггера.
Ну а вот собственно последний рисунок - это апогей демонстрации всей этой технологии. Из SQL-сервера мы отправили число 5 и на удаленном TCP/IP-узле получили это число (и событие SQL-сервера) непосредственно в проге на VB6... При этом наша VB6-прога не подвисала в ожидании сообщений из сети и ничего не опрашивала на SQL-сервере. Просто триггер SQL-сервера вызвал событие у клиентской проги на VB6...
В реальной проге, правда, все это значительно сложнее - ведутся таблицы коннектов, состояния и т.д. Полученные события тоже достаточно непросто обрабатываются - а через цепочку обратной связи снова через коннекты к SQL-серверу вызывают триггеры... Ну и т.д. - это уже получается реальная коммерческая система...
Единственно серьезное отличие (и сложность) при работе на передающей стороне не просто описанной выше проги-Sender, а обьекта, подгружаемого из SQL-сервера по sp_OACreate - то, что SQL-сервер уничтожает экземпляр обьекта, после завершения SQL-BATCH. Естественно виндузня не дает использовать TCP/IP канал сопоставленный уже удаленному классу. Иначе говоря - так как на левом рисунке - работать будет, а так как на правом - нет.
При наличии на передающей части TCP/IP канала SQL-сервера алгоритм использования вышеприведенных прог получается такой:
- Сначала устанавливаем прослушку на клиенте.
- Теперь передаем по установленному нами протоколу данные (который конечно на практике будет сложнее, чем в модулях, приведенных выше). Прога обрабатывает событие SQL-сервера как требуется.
- И вот теперь канал приходится сбрасывать (из-за того, что SQL-сервер убил экземпляр созданного нами класса). Как видите на этом рисунке повторные попытки использования этого EndPoint'а к успеху не привели. Прога эти данные не получила, а при сбросе Listenera - все установленные с мертвыми экземплярами Sender'ов TCP/IP канали сбросились.
|