Сообщений 18 Оценка 501 Оценить |
Мне недавно захотелось пообщаться в локальной сети. Наверняка для этого существует множество программ, но я решил совместить приятное с полезным и написать такую программу на .NET самому.
Преимущества очевидны: ее всегда можно настроить под свои нужды, и попутно разобраться наконец-то, что же такое сокеты, потоки, как поместить иконку приложения в system tray, как читать и сохранять настройки в XML файлы и как, например, проверить, запущено ли уже наше приложение.
Visual Basic and C# Code Samples уже содержат пример, неплохо иллюстрирующий необходимую нам функциональность. В качестве языка выберем VB.NET. Нужный нам пример называется VB.NET - Advanced .NET Framework (Networking) - Use Sockets.
ПРИМЕЧАНИЕ (Загрузить 101 Code Samples можно по адресу http://download.microsoft.com/download/6/4/7/6474467e-b2b7-40ea-a478-1d3296e78adf/CSharp.msi – для C#, и http://download.microsoft.com/download/6/4/7/6474467e-b2b7-40ea-a478-1d3296e78adf/VisualBasic.msi – для VB.NET). |
Исходный пример уже предоставляет всю функциональность чата и разбит на две программы: Client и Server. Клиенты могут посылать текстовые сообщения на сервер, а сервер может рассылать сообщения всем клиентам. Для простоты и сервер, и клиенты в данном примере запускаются на одной машине, но эту функциональность легко приспособить как для локальной сети, так и для связи «более удаленных» компьютеров.
Для новичков: порт на локальной машине – это просто точка подключения, через которую компьютер может общаться с другими по сети. Договоримся, что и клиент, и сервер используют для общения порт номер 10000. Если окажется, что этот порт уже занят другим приложением, придется выбрать другой номер. Для нас важно только, чтобы все экземпляры нашего приложения работали с одним и тем же портом.
Для соединения с удаленным компьютером необходимо знать его адрес и номер порта. В примере используется связь клиентов с сервером на одной и той же локальной машине. В этом случае в качестве адреса достаточно использовать строку localhost. В качестве адресов компьютеров в локальной сети можно использовать их имена.
СОВЕТ Имя своего компьютера можно узнать при помощи вызова: Environment.MachineName. |
Каждый клиент при запуске обращается к серверу. У клиента есть сетевой адрес и номер порта машины, на которой должен быть запущен сервер. В примере от Microsoft они жестко зашиты в код программы.
Для работы с сетью используется протокол TCP – Transmission Control Protocol.
ПРИМЕЧАНИЕ Для работы с TCP и сокетами используйте пространство имен System.Net.Sockets. |
Клиент пытается установить соединение по этому адресу и номеру порта. Если это ему удается (на указанной машине запущен сервер, и он принимает соединение), то между клиентом и сервером устанавливается TCP соединение, и представляемое экземпляром класса System.Net.Sockets.TcpClient. У этого класса есть метод GetStream, возвращающий поток ввода-вывода типа System.Net.Sockets.NetworkStream.
Вот, собственно, и все, что нам нужно. Для разнообразия напишем наше приложение на VB.NET, основываясь на вышеописанном примере. Заранее приношу извинения поклонникам C# и искренне надеюсь, что они без труда разберутся в этом коде. Поскольку код в основном демонстрирует применение объектов различных типов, различия в языках не слишком принципиальны.
Мы хотели бы написать простенький чат (для локальной сети).
При загрузке компьютера в трее должен появляться значок, говорящий, есть ли кто-нибудь из пользователей онлайн. Если да, то можно в любой момент щелкнуть по значку и написать в выскочившем окошке сообщение для всех доступных пользователей. Если пишут нам, то окошко выскочит само, останется только ответить и закрыть его.
Мы поместим клиента и сервер в одно приложение, связывающееся с другими экземплярами по принципу "точка-точка". Оно должно работать постоянно (висеть в трее).
При загрузке приложение должно сыграть роль клиента и опросить всех пользователей из списка на предмет присутствия в сети. Все остальные копии в данном случае будут выступать в роли сервера. С откликнувшимися надо установить соединение.
Затем нужно самому перейти в роль сервера и асинхронно (т.е. одновременно с нашей основной деятельностью) ждать новых пользователей. Для этого нужно слушать порт в отдельном потоке.
Вы заметили, как используются возможности ООП в примере Microsoft? Вот именно. Они практически никак не используются. В коде сервера всего один класс, инкапсулирующий соединение с пользователем. В коде клиента отдельных классов нет. Ну да ладно, их задача была показать нам, как работает System.Net.Sockets – и с ней они справились.
Итак, нам понадобится класс Chat, существующий на протяжении всей работы приложения. Более одного чата в приложении нам не понадобится, поэтому используем для этого класса паттерн Одиночка (Singleton).
Далее. У нас будет поток, который будет слушать порт. Поручим это классу LocalPort. Класс Chat будет иметь private-свойство Listener типа LocalPort.
Теперь нужно создать отдельный класс, хранящий информацию о собеседниках из списка (класс RemoteUser). Кроме этого, потребуется сам список, т.е. просто коллекция пользователей (UserList) и TCP-соединение с удаленным пользователем (UserConnection). Получается примерно следующая архитектура:
Рисунок 1. Архитектура приложения.
Здесь на диаграмме стрелка от A к B с ромбом у A обозначает, что класс A содержит свойство типа B. Таким образом, каждый объект A содержит экземпляр B как часть и может с ним общаться.
ПРИМЕЧАНИЕ Общение в обратном стрелкам направлении осуществляется при помощи событий, т.е. например, класс LocalPort не знает ничего о классе Chat – для общения с ним он генерирует события. |
Класс UserList может содержать любое число объектов класса RemoteUser (он содержит Hashtable в качестве контейнера).
Класс Chat содержит экземпляр класса LocalPort. Этот экземпляр ожидает обращений к порту и сообщает о них своему родительскому объекту Chat. В объекте LocalPort запускается отдельный поток для прослушивания порта.
Кроме того, класс Chat содержит экземпляр класса UserList, который является списком известных пользователей. Он содержит список объектов класса RemoteUser, инкапсулирующий удаленного пользователя из нашего списка контактов. Каждый RemoteUser в свою очередь содержит объект UserConnection – обертку для собственно TCP-соединения – объекта TcpClient.
ПРИМЕЧАНИЕ Проект настолько прост по архитектуре и настолько маловероятно, что он будет развиваться дальше, что я решил обойтись без интерфейсов, напрямую создавая зависимости между классами и указывая классы вместо интерфейсов. |
В основе пользовательского интерфейса будет лежать скрытая главная форма HiddenCarrier, поток которой является основным UI-потоком приложения. Она невидима и содержит все, необходимое для работы.
Можно было бы вообще отказаться от главной формы, запуская приложение из метода Main при помощи Application.Run(). Но форма удобнее тем, что предоставляет очередь сообщений для элемента управления NotifyIcon – она ему нужна для работы. Форма HiddenCarrier содержит также меню и объект Tray класса System.Windows.Forms.NotifyIcon.
Форма MessageWindow будет служить для отображения списка сообщений и ввода нового сообщения. Список сообщений на этой форме представлен с помощью элемента управления RichTextBox.
Ну и наконец, у нас есть два диалога: AddUser для добавления нового пользователя и ContactList для отображения списка всех пользователей.
При запуске программы в системной области панели задач появляется иконка приложения. Ее вид определяет текущее состояние программы:
Рисунок 2. Иконки приложения.
Иконка OnOn означает, что программа работает, и доступен хотя бы один из пользователей.
Иконка OnOff показывает, что ни один из пользователей не находится сейчас онлайн.
OnNA означает, что все остальные пользователи, находящиеся онлайн, попросили их не беспокоить (находятся в режиме “Не беспокоить”).
Аналогично, передний шарик бордового цвета означает, что мы находимся в режиме “Не беспокоить”.
При щелчке левой кнопкой мыши по иконке показывается или снова скрывается окно сообщений. При щелчке правой кнопкой отображается контекстное меню.
Меню выглядит следующим образом:
Show chat | Показывает/скрывает окно сообщений. |
---|---|
Add user | Выводит диалог добавления нового пользователя. |
Contact list | Отображает список пользователей. |
Do not disturb | Включает/выключает режим “не беспокоить”. |
Exit | Завершает работу с программой. |
Окно сообщений (форма MessageWindow) показывается после щелчка мышью по иконке приложения, при выборе пункта меню Show Chat, либо же при поступлении сообщения:
Рисунок 3. Окно сообщений.
Если это окно свернуть или закрыть, программа будет продолжать работать. При получении новых сообщений оно появится опять.
Основным элементом управления этой формы является RichTextBox с диалогом. Наши фразы отображаются синим цветом, фразы других пользователей – красным. В заголовке указывается имя последнего вошедшего в чат пользователя. RichTextBox автоматически прокручивается до конца, когда сообщения начинают переполнять видимую область окна.
Здесь также есть обычный Textbox для ввода своего сообщения и кнопка Send для рассылки этого сообщения всем пользователям.
В этом режиме окно сообщений просто не будет показываться и всплывать наверх при получении новых сообщений, хотя текст будет по-прежнему добавляться в RichTextBox. Это полезно, когда мы, например, смотрим фильм или играем :-)
Пункт меню Add User показывает форму AddUser:
Рисунок 4. Добавление нового пользователя.
Почти вся работа с этим диалогом осуществляется в обработчике меню:
Private Sub mnuAddUser_Click(ByVal sender AsObject, _ ByVal e As System.EventArgs) Handles mnuAddUser.Click Dim AddUserDialog As AddUser = New AddUser() If AddUserDialog.ShowDialog <> DialogResult.OK ThenReturnDim UserName AsString = AddUserDialog.txtName.Text Dim UserAddress AsString = AddUserDialog.txtAddress.Text IfNot ChatServer.Users(UserName) IsNothingThen MessageBox.Show("User " & UserName & " already exists.") Else ChatServer.Users.AddUserOutgoingRequest(UserName, UserAddress) EndIfEndSub |
Здесь мы общаемся с экземпляром ChatServer класса Chat для добавления нового пользователя.
Надо сказать, что свойство Users класса Chat объявлено как public, чтобы облегчить работу со списком пользователей непосредственно из главной формы. Оно возвращает экземпляр контейнера UserList, инкапсулирующего список пользователей.
ПРИМЕЧАНИЕ Здесь используется метод по умолчанию Item контейнера Users, возвращающий объект класса RemoteUser по имени пользователя, или Nothing, если пользователь с таким именем не найден. Можно было бы написать ChatServer.Users.Item(UserName) вместо кода выше. |
Рисунок 5. Список пользователей.
Этот диалог (форма ContactList) отображает список пользователей и их статус. Класс формы не содержит написанного нами кода. Работа с диалогом осуществляется в обработчике меню формы HiddenCarrier:
Private Sub mnuContactList_Click(ByVal sender AsObject, _ ByVal e As System.EventArgs) Handles mnuContactList.Click Dim ContactListDialog As ContactList = New ContactList() Dim User As RemoteUser ForEach User In ChatServer.Users ContactListDialog.lstUsers.Items.Add( _ User.Name & ": " & User.Status.ToString) Next ContactListDialog.ShowDialog() EndSub |
Это пространство имен предоставляет все необходимое для работы с TCP и сокетами. Нас будут интересовать классы NetworkStream, TcpListener и TcpClient.
Функциональность по установке TCP-соединения включает посылку запроса на соединение с одной машины (TcpClient) и прослушивание порта на предмет входящих запросов на другой машине (TcpListener). При клиент-серверном подходе одна программа посылала бы запрос, а вторая слушала бы порт. Поскольку мы хотим написать программу, работающую по принципу "точка-точка", то все эти действия будут выполнять разные части одного приложения.
Этот класс основывается на классе Socket. Он позволяет установить соединение и в дальнейшем обеспечивает передачу и прием данных по сети.
Есть два варианта установки соединения: автоматическая установка соединения в конструкторе при создании объекта TcpClient, либо же соединение вручную при помощи одного из перегруженных вариантов метода Connect. Мы воспользуемся конструктором, принимающим параметры hostname типа String и port типа Integer, и автоматически устанавливающим соединение с указанным портом на компьютере с указанным адресом.
Для примера рассмотрим фрагмент метода ConnectInitiate из класса RemoteUser:
Dim NewClient As TcpClient Try' попытка инициализировать соединение NewClient = New TcpClient(Address, PortNum) ... Catch e As SocketException ... EndTry |
Вот собственно и все. Если соединение установить не удалось, будет сгенерировано исключение, например исключение типа SocketException.
Этот класс также базируется на функциональности класса Socket и представляет более высокоуровневый интерфейс для прослушивания входящих TcpClient-соединений.
В TcpListener тоже есть несколько перегруженных конструкторов – можно указать объект IPEndPoint (инкапсулирующий IP-адрес и номер порта, который необходимо прослушивать), можно указать IP-адрес и номер порта явно.
СОВЕТ Чтобы определить IP-адрес(а) компьютера, можно воспользоваться следующим кодом: |
For Each ip As IPAddress In Dns.Resolve(SystemInformation.ComputerName) _ .AddressList Console.WriteLine(ip.ToString()) Next ip |
В отличие от класса TcpClient, который можно проинициализировать и привести в рабочее состояние непосредственно вызовом конструктора, для начала прослушивания порта TcpListener-ом требуется вызвать метод Start. Соответственно, в конце работы, чтобы остановить прослушивание и закрыть сокет, нужно вызвать метод Stop.
Два метода класса TcpListener служат для синхронного принятия входящих соединений. Метод AcceptSocket ждет соединений с Socket, и при наличии нового соединения возвращает новый объект Socket с информацией о входящем соединении.
Поскольку мы работаем на немного более высоком уровне, воспользуемся вторым методом, а именно методом AcceptTcpClient, который также синхронно возвращает новый объект TcpClient.
"Синхронно" означает, что текущий поток отправляется спать до тех пор, пока не будет обнаружено новое соединение, и метод только потом возвращает управление.
ПРИМЕЧАНИЕ Для работы с потоками используйте пространство имен System.Threading. |
Чтобы ожидание соединения не мешало основной работе, будем осуществлять прослушивание в отдельном потоке. В нашей архитектуре этим занимается объект Listener класса LocalPort. В его конструкторе создается новый поток с основной функцией DoListen:
Использование TcpListener для прослушивания порта:Private ListenerThread As Threading.Thread Private Listener As TcpListener PublicSubNew() ListenerThread = New Threading.Thread(AddressOf DoListen) ListenerThread.Start() ' запустить поток с основной функцией DoListen.EndSubPrivateSub DoListen() Try Listener = New TcpListener(System.Net.IPAddress.Any, PortNum) ' один раз за время исполнения программы: начать прослушивание Listener.Start() Catch e As Exception ... EndTry' основной цикл потока.DoWhileNot ListenerThread IsNothingTry' AcceptTcpClient ожидает новые соединения и возвращает' новое соединение, как только оно было установлено.' UserConnection – обертка для TcpClient.Dim NewUserConnection As UserConnection = _ New UserConnection(Listener.AcceptTcpClient()) ' Добавить временный обработчик события LineReceived,' чтобы поймать первое сообщение этого клиента.AddHandler NewUserConnection.LineReceived, _ AddressOf ReceivedCallback Catch e As Threading.ThreadAbortException ' Это исключение мы ловим по окончании работы потока.EndTryLoopEndSub |
Метод Listener.AcceptTcpClient() возвращает новый объект, который оборачивается в класс UserConnection, облегчающий обмен информацией между участниками чата.
Кроме того, регистрируется обработчик события LineReceived, чтобы осталась ссылка на новый объект UserConnection, и мы могли получать от него сообщения. Если этого не сделать, то наше соединение при первой же возможности приберет к рукам сборщик мусора.
Как только соединение успешно установлено, можно непосредственно передавать данные при помощи потока. У созданного объекта TcpClient есть метод GetStream, возвращающий объект типа NetworkStream. Работать с ним можно, как и с любым другим потоком, ведь класс NetworkStream наследуется от класса System.IO.Stream. Методы CanWrite и CanRead помогут определить, можно ли писать или читать данные в/из этого потока.
Писать текстовые данные в поток очень просто: достаточно создать объект класса IO.StreamWriter, передать в параметр конструктора TcpClient.GetStream и затем использовать методы объекта StreamWriter:
Private Client As TcpClient Private Writer As IO.StreamWriter ... Writer = New IO.StreamWriter(Client.GetStream) ... PublicSub SendData(ByVal Data AsString) Writer.Write(Data) ' убедиться, что данные будут посланы безотлагательно. Writer.Flush() EndSub |
Эта функциональность реализована в методе Send класса UserConnection.
С чтением дело обстоит несколько сложнее. Нам нужно асинхронное чтение – это означает, что активный поток (Thread) попросит TcpClient.GetStream прочитать данные и пойдет дальше, а когда данные действительно будут прочитаны, вызовется Callback-метод. Мы начинаем чтение в конструкторе класса UserConnection:
Public Sub New(ByVal ExistingClient As TcpClient) Me.Client = ExistingClient ' чтобы в дальнейшем писать данные при помощи текстового потокаMe.Writer = New IO.StreamWriter(Client.GetStream) ' Запускает асинхронный поток-читатель.' Данные сохраняются в readBuffer.Me.Client.GetStream.BeginRead(ReadBuffer, 0, BufferSize, _ AddressOf ReceiverCallback, Nothing) EndSub |
При поступлении сообщения будет вызвана функция ReceiverCallback:
Private Sub ReceiverCallback(ByVal ar As IAsyncResult) Dim BytesRead AsIntegerTry' закончить начатое ранее чтение BytesRead = Client.GetStream.EndRead(ar) If BytesRead < 1 Then Terminate() ' соединение разорвано.Return' больше слушать нечего.EndIf' кодировка UTF8 правильно передает русские буквы при работе с потокамиDim strMessage AsString = _ System.Text.Encoding.UTF8.GetString(ReadBuffer, 0, BytesRead) ' начать новый асинхронный процесс чтения («слушать дальше») Client.GetStream.BeginRead(ReadBuffer, 0, BufferSize, _ AddressOf ReceiverCallback, Nothing) ' уведомить мир о прибытии нового текстового сообщенияRaiseEvent LineReceived(Me, strMessage) Catch e As Exception Terminate() ' если что-то пошло не так, закрываем соединениеEndTryEndSub |
При выборе пункта меню Add User появляется диалог, запрашивающий имя нового пользователя и его адрес. Вспомним, где начинается процесс добавления и инициирования соединения:
Private Sub mnuAddUser_Click(ByVal sender AsObject, _ ByVal e As System.EventArgs) Handles mnuAddUser.Click ... Dim UserName AsString = AddUserDialog.txtName.Text Dim UserAddress AsString = AddUserDialog.txtAddress.Text ... ChatServer.Users.AddUserOutgoingRequest(UserName, UserAddress) EndSub |
Вызов ChatServer.Users.AddUserOutgoingRequest добавляет нового пользователя в список Users и делает попытку установить с ним соединение. Если это удалось, пользователь приобретает статус активного (онлайн), иначе остается оффлайн.
Кроме метода AddUserOutgoingRequest класса UserList, имеется также метод AddUserIncomingRequest, добавляющий пользователя, запрос от которого пришел по сети.
Давайте теперь рассмотрим, что именно происходит в методе AddUserOutgoingRequest класса UserList:
Public Sub AddUserOutgoingRequest( ByVal Name AsString, ByVal Address AsString) Dim NewUser As RemoteUser = AddRemoteUser(Name, Address) NewUser.ConnectInitiate(LocalUserName, LocalUserAddress) Save() EndSub |
В методе AddRemoteUser создается новый экземпляр класса RemoteUser, ссылка на который помещается в контейнер и к его событиям подключаются обработчики контейнера.
Private Function AddRemoteUser(ByVal Name AsString, ByVal Address AsString) As RemoteUser Dim NewUser As RemoteUser = New RemoteUser(Name, Address) ' перенаправляем события пользователя на события контейнераAddHandler NewUser.Connected, AddressOfMe.OnUserConnected AddHandler NewUser.Disconnected, AddressOfMe.OnUserDisconnected AddHandler NewUser.ReceivedText, AddressOfMe.OnUserReceivedText AddHandler NewUser.StatusChanged, AddressOfMe.OnUserStatusChanged Add(NewUser) ' собственно добавление в контейнерReturn NewUser EndFunction |
Затем вызывается NewUser.ConnectInitiate (класс RemoteUser):
Public Sub ConnectInitiate(ByVal LocalUserName AsString, ByVal LocalUserAddress AsString) If IsOnline() ThenExitSubDim NewClient As TcpClient Try NewClient = New TcpClient(Me.Address, PortNum) Connection = New UserConnection(NewClient) SendMessage(MessageType.Connect, LocalUserName & "|" & LocalUserAddress) Status = UserStatus.Online RaiseEvent Connected(Me) Catch e As Exception Disconnect() EndTryEndSub |
Внутри этого метода создается экземпляр класса TcpClient, которому в конструкторе передается адрес и номер порта. Ссылка на этот объект передается в конструктор класса UserConnection.
Если соединение прошло успешно, отправляется инициирующее сообщение, которое содержит имя и адрес (имя компьютера). На этом работа на вызывающей стороне заканчивается.
Здесь первым в работу вступает объект класса LocalPort, ожидающий новых соединений в методе DoListen.
Новому соединению назначается временный обработчик событий:
' Добавить временный обработчик события LineReceived, ' чтобы поймать первое сообщение этого клиента. AddHandler NewUserConnection.LineReceived, AddressOf ReceivedCallback |
После установки соединения вызывающая сторона должна прислать свое имя и адрес. Когда это случается, вызывается метод ReceivedCallback, извлекающий имя и адрес вызывающей стороны из присланной строки и генерирующий событие EstablishedConnection.
Класс Chat содержит обработчик этого события:
Private Sub Listener_EstablishedConnection(_ ByVal Name AsString, _ ByVal UserAddress AsString, _ ByVal ExistingConnection As Agent.UserConnection) Handles Listener.EstablishedConnection Dim NewlyConnectedUser As RemoteUser = Users(Name) IfNot NewlyConnectedUser IsNothingThen NewlyConnectedUser.ConnectRespond(ExistingConnection) ElseIf MessageBox.Show("Would you like to add a new user " & Name _ & " to your contact list?", "New user requests connection", _ MessageBoxButtons.YesNo, MessageBoxIcon.Question) = DialogResult.Yes Then Users.AddUserIncomingRequest(Name, UserAddress, ExistingConnection) EndIfEndIfEndSub |
Здесь вызывается метод AddUserIncomingRequest класса UserList. Вот как он выглядит:
Public Sub AddUserIncomingRequest(_ ByVal Name AsString, _ ByVal Address AsString, _ ByVal ExistingConnection As UserConnection) Dim NewUser As RemoteUser = AddRemoteUser(Name, Address) NewUser.ConnectRespond(ExistingConnection) Save() EndSub |
Ну и, наконец, метод ConnectRespond класса RemoteUser выглядит так:
Public Sub ConnectRespond(ByVal ExistingConnection As UserConnection) Connection = ExistingConnection Status = UserStatus.Online RaiseEvent Connected(Me) EndSub |
В этом методе запоминается ссылка на объект Connection класса UserConnection, олицетворяющий TCP-соединение. Статус пользователя меняется на Online. Наконец, генерируется событие Connected, которое порождает цепочку вызовов, заканчивающуюся в классе формы frmHiddenCarrier:
Private Sub ChatServer_UserConnected(ByVal Sender As Agent.RemoteUser) _ Handles ChatServer.UserConnected Log("Connected to user: " & Sender.Name, System.Drawing.Color.Black) UpdateStatusText(Sender.Name & ": Online") IfMe.DoNotDisturb Then Sender.SendStatus(UserStatus.DoNotDisturb) EndSub |
Если мы находимся в данный момент в режиме "Не беспокоить" (DoNotDisturb), то уведомим об этом вызывающую сторону, чтобы она узнала наш статус и могла соответственно изменить у себя вид иконки.
Здесь все начинается на форме MessageWindow. Когда пользователь нажимает на кнопку Send, генерируется событие формы ShouldBroadcastText, которое обрабатывается в HiddenCarrier. Здесь происходит единственный вызов:
ChatServer.Users.SendText(Text) |
Надо сказать, что у класса UserList есть два специальных оповещающих public-метода: SendText и SendStatus. Первый метод рассылает текстовое сообщение всем пользователям:
Public Sub SendText(ByVal Text AsString) Dim u As RemoteUser ForEach u In list.Values u.SendText(Text) NextEndSub |
Метод SendStatus действует аналогично, он только уведомляет пользователей об изменении статуса пользователя (Online или DoNotDisturb).
Вызов SendText в классе RemoteUser запаковывает сообщение в строку и передает ее, вызывая метод SendData своего внутреннего объекта UserConnection:
Public Sub SendData(ByVal Data AsString) If Writer IsNothingOrElse Data.Length < 1 ThenReturn Writer.Write(Data) Writer.Flush() EndSub |
В свойстве Writer мы запомнили экземпляр класса StreamWriter для записи в NetworkStream:
Private Writer As IO.StreamWriter ... Writer = New IO.StreamWriter(Client.GetStream) |
Процесс получения сообщений сильно напоминает процесс получения нового входящего соединения – фактически, это просто цепочка событий, передаваемых из класса UserConnection через RemoteUser и Chat форме HiddenCarrier, где они служат для обновления пользовательского интерфейса.
Есть много способов сохранить набор объектов в XML. Один из них, в данном случае, наверное, самый естественный, это использование XmlSerializer или SoapFormatter и представление необходимых данных в виде некоторого графа объектов. При этом объем кода, необходимого для реализации этой задачи, сократился бы до нескольких строк.
Мы же хотим показать здесь другое, а именно как работать со структурой XML файлов вручную, на низком уровне. Пространство имен System.Xml предоставляет много возможностей. Одна из них – класс XmlDocument, моделирующий «древесную» структуру XML-документа. Он может и читать, и изменять XML-документы, обеспечивая произвольный доступ к узлам (свободно перемещаясь по документу).
Если же нужно просто быстро считать документ последовательно (однопроходно), то можно воспользоваться классом XmlTextReader. Для создания новых XML-документов можно использовать класс XmlTextWriter.
XmlTextReader и XmlTextWriter - это конкретная реализация базовых классов XmlReader и XmlWriter для текстового представления XML.
Класс UserList содержит метод Save для записи в XML-файл списка пользователей:
Пример сохранения данных в XML-файл при помощи XMLTextWriter:Public Sub Save() Dim Writer As XmlTextWriter Dim File As System.IO.StreamWriter Try File = New IO.StreamWriter(FileName, False,System.Text.Encoding.Unicode) Writer = New XmlTextWriter(File) Writer.Formatting = Formatting.Indented '======================================================= Writer.WriteStartDocument() Writer.WriteStartElement("Chat") Writer.WriteStartElement("LocalUser") Writer.WriteElementString("Name", LocalUserName) Writer.WriteElementString("Address", LocalUserAddress) Writer.WriteEndElement() Writer.WriteStartElement("Users") Dim User As RemoteUser ForEach User In list.Values Writer.WriteStartElement("User") Writer.WriteElementString("Name", User.Name) Writer.WriteElementString("Address", User.Address) Writer.WriteEndElement() Next Writer.WriteEndElement() Writer.WriteEndElement() Writer.WriteEndDocument() '======================================================FinallyIfNot Writer IsNothingThen Writer.Close() EndIfEndTryEndSub |
Вначале в ветку LocalUser записываются имя и адрес главного пользователя на локальной машине, а затем в ветку Users по очереди добавляются ветки User для каждого пользователя, содержащие его имя и адрес.
Пример создаваемого XML-файла:<?xml version="1.0" encoding="utf-16"?> <Chat> <LocalUser> <Name>Bill</Name> <Address>BILLSPC</Address> </LocalUser> <Users> <User> <Name>Melinda</Name> <Address>MELINDASPС</Address> </User> </Users> </Chat> |
Public Sub Load() TryDim Doc As XmlDocument = New XmlDocument() Doc.Load(FileName) Dim Node1 As XmlNode = Doc.SelectSingleNode("/Chat/LocalUser") LocalUserName = Node1("Name").InnerText LocalUserAddress = Node1("Address").InnerText Dim Nodes As XmlNodeList = Doc.GetElementsByTagName("User") ForEach Node1 In Nodes Dim NewName AsString = Node1("Name").InnerText Dim NewAddress AsString = Node1("Address").InnerText Dim NewUser As RemoteUser = AddRemoteUser(NewName, NewAddress) NextCatch e As System.IO.FileNotFoundException Finally ... EndTryEndSub |
Этот фрагмент считывает всю структуру XML-документа из файла с именем FileName. Для этого используется метод Load класса XmlDocument. После его выполнения объект Doc наполняется содержимым, моделирующим в памяти структуру XML-файла.
Метод SelectSingleNode возвращает объект XmlNode, находящийся в этой «древесной» структуре по указанному адресу. Обратите внимание, что запись Node1(“Name”) является сокращением от Node1.Item(“Name”), свойства по умолчанию VB.NET, которое возвращает для объекта XmlNode дочерний объект XmlElement по его имени.
Мы используем свойство InnerText, чтобы получить значения, сохраненные в XmlElement.
Наконец, вызов GetElementsByTagName возвращает список узлов, имеющих указанное имя.
Для каждого узла, описывающего пользователя, мы вызываем метод AddRemoteUser класса UserList, и передаем ему имя и адрес в качестве параметров.
В любом Windows-приложении может быть один или более UI-потоков (thread). UI-поток – это поток, в котором запущена очередь обработки сообщений Windows. Любая форма может принадлежать только одному UI-потоку. В .NET запуск очереди сообщений производится вызовом метода Application.Run(...).
ПРЕДУПРЕЖДЕНИЕ В многопоточных приложениях с графическим интерфейсом следует помнить следующее: В принципе, все элементы управления Windows потокобезопасны, так как любое общение с ними ведется через сообщения, а механизм передачи сообщений в Windows автоматически гарантирует маршаллинг вызовов. Любое сообщение помещается в очередь, а его выборка для обработки производится уже из того самого UI-потока. К сожалению, многие элементы управления, входящие в состав Windows Forms и являющиеся обертками над элементами управления Windows хранят собственную информацию и могут некорректно себя вести при параллельных обращениях из разных потоков. В текущей версии Windows Forms никаких проверок не делается, и такие обращения могут порождать очень неприятные и трудноуловимые ошибки. В .NET Framework 2.0 специально для выявления параллельных обращений встроен код, проверяющий, из какого потока производится вызов, и генерирующий исключение, если вызов метода control-а производится не из того потока, с которым ассоциировано окно control-а. Хотя окна верхнего уровня и могут быть созданы в разных потоках, их дочерние окна должны создаваться в том же потоке, что и родительские. |
Как же обращаться к методам форм и control-ов, созданных в некотором UI-потоке, из других потоков? Классический пример: поток с вычислениями просит основную форму показать индикатор прогресса.
Для этих целей у каждого control-а есть ряд методов, вызов которых является безопасным из любого потока: это Control.Invoke, Control.BeginInvoke, Control.EndInvoke, Control.InvokeRequired. Этого вполне достаточно, чтобы обратиться к любому члену Control-а.
InvokeRequired позволяет узнать, нужно ли для обращения к данному control-у менять поток при помощи Invoke, или текущий поток уже является UI-потоком этого control-а. В последнем случае (InvokeRequired возвращает False) можно обращаться к control-у напрямую.
Методы и свойства control-а могут быть вызваны из другого потока синхронно (при помощи Invoke) и асинхронно (при помощи BeginInvoke). Вызов в обоих случаях осуществляется при помощи делегата. EndInvoke служит для того, чтобы обработать результат асинхронного вызова, начатого при помощи BeginInvoke.
Как правило, рекомендуется вызывать BeginInvoke вместо Invoke (неблокирующий вызов предохраняет от возможных deadlock’ов). Кроме того, из соображений простоты и эффективности рекомендуется пользоваться EndInvoke, только когда без этого действительно не обойтись (потому что блокирующее ожидание опять-таки чревато возможными заклиниваниями и прочими неприятностями).
Делегаты в .NET имеют методы с похожими названиями. Однако с одноименными методами control-ов BeginInvoke и EndInvoke они не имеют ничего общего. Более того, они предназначены для решения противоположной задачи. Они позволяют вызвать метод, на который ссылается делегат, в одном из потоков пула.
В нашем приложении форма frmHiddenCarrier получает события от класса Chat. Эти события запускаются в произвольном потоке из пула, и поэтому нам необходимо гарантировать, что обработчики этих событий корректно обращаются к объектам пользовательского интерфейса. Рассмотрим пример:
Событие ChatServer_UserConnected вызывает метод Log, который обращается к форме MessageWindow, чтобы дописать туда текстовое сообщение.
Private Delegate Sub LogDelegate(ByVal Text AsString, _ ByVal ForeColor As System.Drawing.Color) PrivateSub Log(ByVal Text AsString, ByVal ForeColor As System.Drawing.Color) ' является ли вызвавший поток родным потоком окна MessageWindow?IfNot MessageWindow.InvokeRequired Then' вызвать родной, незащищенный метод формы MessageWindow.Log(Text, ForeColor) Else' косвенный вызов методаDim d As System.Delegate = New LogDelegate(AddressOf Log) ' асинхронно вызвать этот же метод, с теми же параметрами. MessageWindow.BeginInvoke(d, NewObject() {Text, ForeColor}) EndIfEndSub |
Итак, пояснение. LogDelegate является типом делегата для вызова метода Log.
В самой функции проверяется, разрешено ли текущему потоку напрямую обращаться к членам MessageWindow. Если да, то мы обращаемся к UI-потоку напрямую.
В случае же, когда необходимо вызвать метод формы из другого потока, мы создаем новый объект-делегат, указывающий на эту же функцию, и асинхронно вызываем его при помощи BeginInvoke с двумя параметрами. Второй параметр – это массив Object, который служит параметрами для вызываемого метода. К сожалению, здесь не избежать упаковки типов значений (boxing) – но это мелочи по сравнению с затратами на сам вызов.
Если вызываемый метод не имеет параметров, можно вызвать перегруженный вариант BeginInvoke(d) с одним параметром, без тяжеловесного второго параметра.
Это можно сделать при помощи элемента управления NotifyIcon. У него есть несколько простых свойств, которые позволяют назначить иконку, текст всплывающей подсказки и контекстное меню, появляющееся при правом щелчке по иконке. Свойство NotifyIcon.Visible определяет, отображать ли иконку в трее.
Иконка, как правило, нужна в приложениях, не имеющих главной формы. Поэтому в этом случае можно создать объект иконки, видимый все время жизни приложения, вручную (т.е. без помощи дизайнера VS) создавать контекстное меню и устанавливать свойства NotifyIcon.
Можно пойти другим путем. Если у нас есть постоянно существующая форма, пусть даже скрытая, то можно воспользоваться преимуществами визуального дизайнера и добавить объект NotifyIcon в область компонентов формы. Тогда можно удобно изменять свойства объекта и легко присвоить ему контекстное меню, созданное в визуальном дизайнере.
Во-первых, нужно добавить файл иконки в проект и указать в его свойствах Build Action = Embedded Resource.
Один из конструкторов System.Drawing.Icon(Me.GetType, "MyIcon.ico") принимает два параметра – тип, объявленный в сборке, содержащей ресурс, и собственно имя файла ресурса. Подобные конструкторы есть и у Bitmap, и у других типов графических изображений.
Так что для использования достаточно создать новую иконку при помощи этого конструктора. Однажды извлечённые иконки можно занести в хеш-таблицу для дальнейшего быстрого использования.
Осуществить это можно при помощи глобально видимых системных объектов – т.н. мьютексов (System.Threading.Mutex):
Public InstanceMutex As System.Threading.Mutex ' глобально видимый объектFunction IsAlreadyRunning() AsBoolean' Имя должно быть по возможности уникальным, ' чтобы не мешаться с другими приложениями.Const UniqueString AsString = "LANChatAgentByKirillOsenkov"' Возвращаемое значение (параметр out в C#):' True если мьютекс найден не был и был только что создан' False если мьютекс уже существовал на локальной машине.Dim createdNew AsBoolean = False' существует ли уже мьютекс с таким именем? InstanceMutex = New Mutex(False, UniqueString, createdNew) ReturnNot createdNew EndFunction |
Экземпляр мьютекса должен существовать все время работы приложения (как бы сигнал всем другим о том, что одно приложение уже запущено). Весь код следует разместить в модуле VB (или сделать public static в C#).
Теперь достаточно добавить строчку “If IsAlreadyRunning() Then Return” в метод Main, и дело в шляпе.
Хорошим тоном, кроме того, считается не забыть вручную закрыть мьютекс при окончании работы приложения:
Sub Main() If IsAlreadyRunning() ThenReturn' если приложение уже запущено, выйти Application.Run(New frmHiddenCarrier()) InstanceMutex.Close()' не забыть закрыть мьютекс.EndSub |
В VB 6.0 одним из неоспоримых достоинств была возможность определить в событии QueryUnload, что вызвало закрытие формы: нажатие на крестик, снятие задачи, завершение сеанса работы Windows или программное закрытие.
В .NET событие Closing такой возможности не предоставляет: все, что у нас есть, это возможность отменить закрытие.
Одна из возможностей узнать причину – изучение стека вызова в момент обработки события. Она не очень надежна, потому что сильно зависит от схемы вызова и обработки оконных событий. Тем не менее, она работает:
Анализ содержимого стека:Imports System.ComponentModel Imports System.Diagnostics Imports System.Windows.Forms PublicClass SampleForm : Inherits Form ... PrivateSub SampleForm_Closing(ByVal sender AsObject, _ ByVal e As CancelEventArgs) HandlesMyBase.Closing Dim f As StackFrame = New StackTrace(True).GetFrame(7) SelectCase f.GetMethod().Name Case"SendMessage" MessageBox.Show("Приложение закрывается программно.") Case"CallWindowProc" MessageBox.Show("Приложение закрывается пользователем.") Case"DispatchMessageW" MessageBox.Show("Приложение закрывается из Task Manager.") CaseElse MessageBox.Show("Приложение закрывается по неведомой причине.") EndSelectEndSubEndClass |
Узнать, являлось ли причиной нажатие на крестик или Close в системном меню окна, можно при помощи просмотра оконных сообщений:
Subclassing:' Причина закрытия формы Private ClosedFromUI AsBoolean = FalsePrivateSub frmMessage_Closing(_ ByVal sender AsObject, _ ByVal e As System.ComponentModel.CancelEventArgs) HandlesMyBase.Closing If ClosedFromUI Then ... ClosedFromUI = FalseEndIfEndSubPrivateConst SC_CLOSE As Int32 = &HF060 PrivateConst SC_MINIMIZE AsLong = &HF020& PrivateConst WM_SYSCOMMAND As Int32 = &H112 ProtectedOverridesSub WndProc(ByRef m As System.Windows.Forms.Message) If m.Msg = WM_SYSCOMMAND ThenIf m.WParam.ToInt32() = SC_CLOSE Then' Нажата кнопка Close. ClosedFromUI = TrueElseIf m.WParam.ToInt32 = SC_MINIMIZE Then' Нажата кнопка Minimize. MinimizedToTray = TrueReturnEndIfEndIfMyBase.WndProc(m) EndSub |
Можно отловить и завершение сессии Windows, достаточно обрабатывать сообщение WM_QUERYENDSESSION.
ПРИМЕЧАНИЕ В версии .NET Framework 2.0 появилась возможность определить причину закрытия формы, использовать данные приемы приходится только в версиях 1.0 и 1.1. |
Свойство ControlBox управляет всем системным меню. Чтобы запретить использование "крестика", нужно переопределить свойство CreateParams формы:
Protected Overrides ReadOnly Property CreateParams() As CreateParams GetDim cp As CreateParams = MyBase.CreateParams Const CS_DBLCLKS As Int32 = &H8 Const CS_NOCLOSE As Int32 = &H200 cp.ClassStyle = CS_DBLCLKS Or CS_NOCLOSE Return cp EndGetEndProperty |
Иногда (как в нашем случае) может понадобиться иметь основной UI-поток приложения и цикл обработки оконных сообщений, но так, чтобы основное окно было невидимым.
Вариант Application.Run(), запускающий message loop без окна, может не подходить по причинам многопоточного взаимодействия, например, когда нужно обращаться к NotifyIcon из других потоков, и NotifyIcon не принадлежит ни одной форме, могут возникнуть проблемы (отсутствие основного UI-потока).
К сожалению, метод Application.Run, принимающий форму в качестве параметра, в процессе работы вызывает метод Show у переданной ему формы. Таким образом, устанавливать свойство Visible в False в обработчике Load или в конструкторе формы бессмысленно. Так что окно обязательно появится на экране хотя бы на краткий момент, и даже если его сразу скрыть, оно все равно мелькнет, а это выглядит крайне непрофессионально.
Есть простое решение этой проблемы. Можно выставить свойство окна WindowState в Minimized, что приведет к созданию окна в минимизированном режиме, а значит, на экране оно не появится.
Чтобы окно не отображалось и в панели задач, следует установить свойство ShowInTaskbar в False.
Проблема в том, что когда окно свернуто, и ShowInTaskbar = False, свернутое окно все равно появляется в левом нижнем углу экрана (обычно над кнопкой Пуск), как это было в Windows 3.11. Поэтому нужно дополнительно установить Opacity = 0, иначе форма будет показана независимо от ее свойства Visible.
Стиль главного окна приложения определяет, показывать ли окно в списке задач. Если окну установить, например, стиль FixedToolWindow, то Windows не будет считать его полноценным окном, и, следовательно, приложение исчезнет из списка открытых окон и из списка работающих приложений в task manager.
Естественно, этот прием не скрывает процесс из списка процессов.
К сожалению, элемент управления RichTextBox в Windows Forms совсем не так богат возможностями, как это следовало бы из его названия. С программным скроллингом дела обстоят вообще туго. Метод ScrollToCaret, например, работает только тогда, когда на RichTextBox находится фокус. Это часто бывает неудобно.
Следующая функция немного упрощает дело, прокручивая *TextBox до конца и не требуя, чтобы фокус ввода находился на control-е:
Const WM_VSCROLL AsInteger = &H115 Const SB_BOTTOM AsInteger = 7 PublicDeclareFunction SendMessage Lib"user32"Alias"SendMessageA" ( _ ByVal hWnd As IntPtr, _ ByVal msg AsInteger, _ ByVal wParam AsInteger, _ ByRef lParam As POINTAPI) As IntPtr PublicSub ScrollToEnd(ByVal Handle As IntPtr, ByVal TextBoxHeight AsInteger) If Handle.Equals(IntPtr.Zero) ThenReturn SendMessage(Handle, WM_VSCROLL, SB_BOTTOM, Nothing) EndSub |
Сообщений 18 Оценка 501 Оценить |