Сообщений 18    Оценка 501        Оценить  
Система Orphus

Tutorial: чат на .NET

или основы WinForms, Sockets, Threading и пр.

Автор: Кирилл Осенков
Источник: RSDN Magazine #4-2004
Опубликовано: 13.03.2005
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Мотивация
Основные понятия
Проектирование чата
Что мы хотим?
Клиент-сервер vs. точка-точка
Распределение ролей и немного ООП
Работа с программой
Иконка в панели задач
Контекстное меню
Окно сообщений
Добавление нового пользователя
Список пользователей
System.Net.Sockets
TcpClient
TcpListener
NetworkStream
Реализация основной функциональности
Добавление нового пользователя и установка соединения
Отправка сообщений
Получение сообщений
Хранение списка пользователей в XML
WinForms и многопоточность
UI поток
Обращения к элементам управления из других потоков
Пример применения BeginInvoke
Полезности
Как поместить иконку в панель задач (system tray)
Как хранить и использовать иконки в виде ресурсов
Как узнать, запущен ли уже экземпляр приложения
Как узнать причину, по которой закрывается форма (Closing)
Как сделать крестик в системном меню окна недоступным
Как сделать основное окно приложения скрытым
Как спрятать приложение от списка задач и от переключения Alt+Tab
Как программно прокрутить текст в RichTextBox до конца
Ссылки (Windows Forms FAQ):

Исходные тексты

Введение

Мотивация

Мне недавно захотелось пообщаться в локальной сети. Наверняка для этого существует множество программ, но я решил совместить приятное с полезным и написать такую программу на .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.

TCP и сокеты

Каждый клиент при запуске обращается к серверу. У клиента есть сетевой адрес и номер порта машины, на которой должен быть запущен сервер. В примере от Microsoft они жестко зашиты в код программы.

Для работы с сетью используется протокол TCP – Transmission Control Protocol.

ПРИМЕЧАНИЕ

Для работы с TCP и сокетами используйте пространство имен System.Net.Sockets.

Клиент пытается установить соединение по этому адресу и номеру порта. Если это ему удается (на указанной машине запущен сервер, и он принимает соединение), то между клиентом и сервером устанавливается TCP соединение, и представляемое экземпляром класса System.Net.Sockets.TcpClient. У этого класса есть метод GetStream, возвращающий поток ввода-вывода типа System.Net.Sockets.NetworkStream.

Итак

Вот, собственно, и все, что нам нужно. Для разнообразия напишем наше приложение на VB.NET, основываясь на вышеописанном примере. Заранее приношу извинения поклонникам C# и искренне надеюсь, что они без труда разберутся в этом коде. Поскольку код в основном демонстрирует применение объектов различных типов, различия в языках не слишком принципиальны.

Проектирование чата

Что мы хотим?

Мы хотели бы написать простенький чат (для локальной сети).

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

Клиент-сервер vs. точка-точка

Мы поместим клиента и сервер в одно приложение, связывающееся с другими экземплярами по принципу "точка-точка". Оно должно работать постоянно (висеть в трее).

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

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

Распределение ролей и немного ООП

Базовая архитектура

Вы заметили, как используются возможности ООП в примере 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

System.Net.Sockets

Это пространство имен предоставляет все необходимое для работы с TCP и сокетами. Нас будут интересовать классы NetworkStream, TcpListener и TcpClient.

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

TcpClient

Этот класс основывается на классе 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.

TcpListener

Этот класс также базируется на функциональности класса 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, и мы могли получать от него сообщения. Если этого не сделать, то наше соединение при первой же возможности приберет к рукам сборщик мусора.

NetworkStream

Как только соединение успешно установлено, можно непосредственно передавать данные при помощи потока. У созданного объекта 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

Запись и чтение XML-файлов

Есть много способов сохранить набор объектов в XML. Один из них, в данном случае, наверное, самый естественный, это использование XmlSerializer или SoapFormatter и представление необходимых данных в виде некоторого графа объектов. При этом объем кода, необходимого для реализации этой задачи, сократился бы до нескольких строк.

Мы же хотим показать здесь другое, а именно как работать со структурой XML файлов вручную, на низком уровне. Пространство имен System.Xml предоставляет много возможностей. Одна из них – класс XmlDocument, моделирующий «древесную» структуру XML-документа. Он может и читать, и изменять XML-документы, обеспечивая произвольный доступ к узлам (свободно перемещаясь по документу).

Если же нужно просто быстро считать документ последовательно (однопроходно), то можно воспользоваться классом XmlTextReader. Для создания новых XML-документов можно использовать класс XmlTextWriter.

XmlTextReader и XmlTextWriter - это конкретная реализация базовых классов XmlReader и XmlWriter для текстового представления XML.

Сохранение списка пользователей в 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>
Пример чтения списка пользователей из XML-файла при помощи XmlDocument:
          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, и передаем ему имя и адрес в качестве параметров.

WinForms и многопоточность

UI поток

В любом 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 они не имеют ничего общего. Более того, они предназначены для решения противоположной задачи. Они позволяют вызвать метод, на который ссылается делегат, в одном из потоков пула.

Пример применения BeginInvoke

В нашем приложении форма 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) с одним параметром, без тяжеловесного второго параметра.

Что почитать:

Полезности

Как поместить иконку в панель задач (system tray)

Это можно сделать при помощи элемента управления 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

Как узнать причину, по которой закрывается форма (Closing)

В 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.

Как спрятать приложение от списка задач и от переключения Alt+Tab

Стиль главного окна приложения определяет, показывать ли окно в списке задач. Если окну установить, например, стиль FixedToolWindow, то Windows не будет считать его полноценным окном, и, следовательно, приложение исчезнет из списка открытых окон и из списка работающих приложений в task manager.

Естественно, этот прием не скрывает процесс из списка процессов.

Как программно прокрутить текст в RichTextBox до конца

К сожалению, элемент управления 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

Ссылки (Windows Forms FAQ):


Эта статья опубликована в журнале RSDN Magazine #4-2004. Информацию о журнале можно найти здесь
    Сообщений 18    Оценка 501        Оценить