Проект ChatWork выполнен в качестве учебной работы по итогам изучения основ языка Java на курсе Нетологии.
ChatWork является клиент-серверным многопоточным приложением, воплощающим требования, изложенные в ТЗ.
Программный комплекс состоит из двух приложений: Чат-Сервер и Чат-Клиент.
Функционал заключается в общении нескольких пользователей Клиента посредством текстовых сообщений в консоли, которое обеспечивается их подключением к общему Серверу.
Исходный код разделён на три пакета:
- common содержит описание протокола взаимодействия (классы MessageType и Message) и утилиты, необходимые для работы обоих приложений (классы Configurator, Logger и LogWriter).
- server содержит классы Сервера: Server, Dispatcher, Connection, а также TextConstants.
- client содержит классы Клиента: Client и Receiver.
Сервер запускается через метод server.Server.main(), Клиент запускается через метод
client.Client.main(). Предполагается работа нескольких пользователей клиентской части
с разных машин. Для демонстрационной работы программы в IntelliJ IDEA для каждого клиента
создаётся отдельная конфигурация запуска.
Параметры запуска сервера и клиента загружаются из конфигурационных файлов:
в начале исполнения их методов main() создаётся экземпляр соответствующего класса на
основе значений, полученных из указанного файла. Формат файла настроек см. в разделе про Конфигуратор.
Для Сервера в данной реализации используется только файл настроек по умолчанию: settings.ini, ссылка на него хранится в статической константе. Если в файле отсутствуют какие-либо настройки, они восполняются значениями по умолчанию.
Для Клиента основным способом задания файла настроек является его указание в качестве
аргумента методу main() в командной строке или в соответствующей строке конфигурации
запуска в IDEA. Данная реализация включает три тестовых файла: client_1.ini, client_2.ini и
client_3.ini.
В случае запуска Клиента без аргумента, или если переданный аргумент не является именем
существующего файла, приложение будет предлагать указать его вручную, пока не будет получено
имя доступного файла. Если указанный файл таки не окажется корректным для приложения файлом
настроек, все параметры будут заполнены по умолчанию. Также по умолчанию будут выставлены
все настройки, которые не будут найдены в файле.
Взаимодействие Клиентов и Сервера осуществляется посредством обмена Сообщениями (объектами класса Message) через устанавливаемые Клиентами до Сервера сокетные http-соединения.
Сериализуемый класс Message содержит четыре по́ля:
final private MessageType type= Тип Сообщения, определяющий алгоритм его обработки при получении (описание типов см. ниже).final private String sender= отправитель сообщения: для порождаемых Клиентом – текущее имя пользователя; для серверных сообщений -null, либо, если это условный сигнал на завершение соединения, пустая строка ("").private String addressee= адресат сообщения: для публичных сообщений от Чат-Клиента (то есть адресованных всем подключённым участникам беседы) и сообщений-запросов –null, для персональных сообщений определённому пользователю – зарегистрированное на Чат-Сервере имя получателя. Чат-Сервер в серверном сообщении заполняет это полеnull, пока пользователь не зарегистрирован в Диспетчере – после же когда регистрация состоялась, получатель должен быть явно указан, поэтому присутствует метод.setAdressee(String имя_получателя)для выставления адресатов, когда одинаковое серверное сообщение рассылается всем подключённым пользователям. По значению этого поля в серверном сообщении Чат-Клиент определяет, что запрошенное (при регистрации либо по ходу беседы) имя принято и зарегистрировано на Чат-Сервере.final private String message= собственно текст сообщения; в сообщениях-запросах (т.е. кроме серверных, публичных и персональных) -null.
Кроме методов доступа к полям, класс Message предоставляет статические методы для создания экземпляров (открытого конструктора в классе нет):
public static Message fromClientInput(String inputText, String sender)Создаёт на клиентской стороне экземпляр сообщения на основе пользовательского ввода и указанного имени отправителя (берётся из имени пользователя).public static Message fromServer(String messageText, String receiver)Создаёт на серверной стороне серверное сообщение с указанным текстом, адресованное указанному получателю.public static Message fromServer(String messageText)Создаёт на сервере серверное сообщение с указанным текстом без указания получателя (предполагается, что получатели будут выставлены отдельно).public static Message registering(String putName)Создаёт на клиентской стороне сообщение с запросом регистрации указанного имени.public static Message stopSign(String message, String recipient)Создаёт стоп-сообщение с указанным текстом для указанного получателя.
А также вспомогательные методы:
-
public static boolean isAcceptableName(String name)Инструментальная функция, проверяющая, является ли строка допустимой в качестве регистрируемого имени пользователя. В настоящей реализации используется проверка соответствия регулярному выражению"[\\p{L}]+\\d*\\s*". -
public boolean isStopSign()- является ли сообщение сигналом о завершении. -
public boolean isServerMessage()- относится ли сообщение к типуSERVER_MSG. -
public boolean isTransferable()- относится ли сообщение к типуTXT_MSGилиPRIVATE_MSG. -
public boolean isRequest()- относится ли сообщение к типамREG_REQUEST,LIST_REQUEST,EXIT_REQUESTилиSHUT_REQUEST -
метод
.toString()используется для представления Сообщения в консоли. Основной шаблон включает имя отправителя + " > " (если отправитель неnull) + текст сообщения (если оно неnull). Для типов кроме публичного этот шаблон предваряется отдельной строкой с явным описанием типа для личных и серверных или с условным обозначением типа для сообщений-запросов.
Всё взаимодействие обеспечивается с помощью семи Типов Сообщения, перечисленных в классе MessageType:
SERVER_MSG= Серверное Сообщение, т.е. информационное сообщение от Чат-Сервера.TXT_MSG= Публичное Сообщение, т.е. обычное сообщение, посылаемое пользователем Чат-Клиента и затем рассылаемое Чат-Сервером всем подключённым участникам беседы, кроме отправителя.PRIVATE_MSG= Частное Сообщение, посылаемое пользователем Чат-Клиента и затем пересылаемое Чат-Сервером указанному в качестве получателя участнику, если таковой подключён.REG_REQUEST= запрос на регистрацию от пользователя.LIST_REQUEST= запрос от клиента на получение списка подключённых пользователей.EXIT_REQUEST= запрос от клиента на выход из чата.SHUT_REQUEST= запрос от клиента на остановку работы сервера. В данной реализации это единственный корректный способ остановить Чат-Сервер.
В реализации используется обращение к элементам перечисления по .ordinal(), это требует внимания
при внесении изменений в типы сообщения.
Типы сообщений делятся на три группы сообразно своему происхождению и назначению:
- Серверные сообщения отправляются от сервера клиентам.
- Передаваемые сообщения отправляются клиентами и перенаправляются сервером клиентам же.
- Сообщения-запросы отправляются клиентами на сервер в качестве команд.
На принадлежность сообщения к группе указывают его методы .isServerMessage(), .isTransferable() и .isRequest().
Стоп-Сообщение - это разновидность серверного сообщения с пустой строкой в поле отправителя.
Клиент, получив такое сообщение, выводит его текст (если он присутствует) в консоль и
разрывает соединение. Свойство оглашается через .isStopSign().
Серверная часть состоит из следующих компонентов:
- класс Server – Сервер слушает за входящие на серверный порт подключения.
- класс Connection – Соединение обслуживает обмен сообщениями с конкретным подключённым клиентом.
- класс Dispatcher – Диспетчер хранит карту соответствий зарегистрированных имён и соединений, осуществляет направление сообщений между участниками общения и обработку сообщений-запросов.
- класс TextConstants - используется в качестве ресурса строковых литералов, в потенциале может предоставлять локализации пользовательского интерфейса.
По инициализации, Сервер создаёт в себе новые Диспетчер и Логировщик и
начинает слушать входящие подключения на порту, указанном в поле server.Server.PORT.
Обнаружив подключение и установив сокетное соединение с клиентом, он создаёт и запускает в обойме потоков
новый объект класса Соединение, передавая в него только что полученный сокет и ссылку на себя. Затем,
если не снят соответствующий флажок, возвращается к началу цикла ждать очередного подключения.
Экземпляр-соединение начинает работать с подключённым клиентом, сперва запуская процедуру регистрации, затем работая на приём поступающих от него сообщений (которые обрабатывает самостоятельно либо передаёт Диспетчеру) и отправку сообщений (порождённых самим соединением или Диспетчером). Соединение находится в одном из двух режимов: локальный и глобальный. В локальном режиме обработка поступившего сообщения проходит в самом соединении, в глобальном же сообщение передаётся на Диспетчер. В локальном режиме соединение находится в начале установления сеанса, когда идёт процедура регистрации, по окончанию которой соединение входит в глобальный режим, основной режим работы чат-хаба. В данной реализации переход в локальный режим используется только для процедуры запроса пароля на доступ к серверу (в данной реализации - к команде выключения).
Диспетчер содержит в себе реестр зарегистрированных подключённых клиентов и предоставляет к нему доступ. Также он получает от соединений сообщения и обрабатывает их сообразно типу. Получая передаваемое сообщение, он пересылает его указанному адресату или всем собеседникам пославшего (если сообщение публичное). Получая сообщение-запрос, запускает соответсвующую процедуру. При этом он может генерировать исходящие серверные сообщения: уведомления участникам, ответы на запросы.
Команда на завершение сеанса, соответствующая пользовательскому вводу "/exit", отправляется
сообщением типа EXIT_REQUEST. Диспетчер, получив его, отключает запросившего выход участника
и рассылает оставшимся информационное серверное сообщение.
Остановка Сервера в данной реализации возможна только через отправку ему от Клиента сообщения
типа SHUT_REQUEST, соответствующего пользовательскому вводу "/terminate". Получив такой
запрос, Диспетчер делегирует разобраться с ним приславшему экземпляру Соединения. Оно входит
в локальный режим, предлагает прислать пароль (задаваемый серверу в файле настроек), получает
его из следующего принятого сообщения и в виде массива байтов вкладывает его в метод
server.Server.stopServer(), затем возвращается в глобальный режим. Если предложенный ключ
совпал с замком, Сервер выходит из цикла ожидания подключений (для чего провоцирует сам с собой
соединение), реализованного в методе .listen(), и переходит к процедуре .exit(): распоряжается
Диспетчеру завершить все сеансы, пытается завершить все потоки соединений и останавливает логирование.
Диспетчер рассылает всем подключённым уведомления со стоп-сигналом и отключает их.
Служит для инициализации и интеграции компонентов серверной части и для установления соединений с клиентами.
private static final Path settingsSource = Path.of("settings.ini")путь к файлу настроек. В данной реализации является единственным способом задания источника настроек.private static final String host_default = "localhost"имя сервера.private static final int port_default = 7777порт для приходящих соединений.private static final byte[] password_default = "0000".getBytes()пароль для управления.
public final String HOSTиpublic final int PORT= адрес и порт, по которым сервер доступен в сети.private final byte[] PASSWORD= пароль для подтверждения управляющих команд на сервере.private final boolean LOG_INBOUND, LOG_OUTBOUND, LOG_TRANSFERRED, LOG_EVENTS= настройки логировщика: будут ли протоколироваться соответственно: входящие сообщения (запросы), исходящие (серверные) сообщения, переправляемые сообщения и возникающие события (установление и закрытие соединений, регистрация, перерегистрация и выход участников, а также всевозможные ошибки).
private final ExecutorService connections= пул потоков адаптивного размера, в котором будут исполняться потоки-соединения.final Dispatcher users= нужный для работы серверной части в целом экземпляр Диспетчера.final Logger logger= используемый всей серверной частью экземпляр Логировщика.
private volatile boolean listening= находится ли сервер в состоянии ожидания новых подключений.
После назначения полей генерируется Логировщик на основе установленных настроек. Имя лог-файла устанавливается "server.log". Создаётся чистый Диспетчер.
public Server()= не используемый в реализации конструктор, создающий экземпляр с настройками по умолчанию. Хост, порт и пароль принимаются из глобальных констант,LOG_OUTBOUNDпринимается true, остальные настройки логировщика false.public Server(String host, int port, byte[] password)= также не используется в реализации. Хост, порт и пароль принимаются из аргументов, а настройки логирования, как в предыдущем конструкторе, по умолчанию.public Server(Path settingFile)= практический конструктор, загружающий с помощью конфигуратора настройки из файла по указанному пути. Если файл не окажется существующим или корректным, все настройки будут приняты по умолчанию. Если какие-то настройки не будут из него прочитаны, также по умолчанию будут восполнены.
-
public static void main()→ Запуск сервера происходит с этой точки, метод олицетворяет жизненный цикл сервера:- создание экземпляра Сервера (на основе указанного файла настроек);
- запуск метода
.listen()- в цикле ожидание и установление подключений; - по выходу из рабочего цикла – процедура
.exit().
-
private void listen()- рабочий цикл, продолжающийся покаlistening = true: обнаружение нового подключения, его логирование и запуск в пуле нового потока типа Соединение. Если требуется запуск сервера из другого класса, метод должен будет быть объявлен открытым. -
private void exit()- процедура финализации, включающая закрытие соединений и остановку запущенных потоков. -
public void stopServer(byte[] gotPassword)- открытый метод для остановки сервера. Принимает пароль и, если он совпал с заданным при создании сервера, устанавливаетrunning = falseи провоцирует фантомным соединением выход из рабочего цикла.
Служит для контроля списка подключённых, пересылки сообщений и прочей логики взаимодействия клиентов с хабом.
private final Server host= ссылка на сервер, создавший этот диспетчер.private final Map<String, Connection> users= "реестр": карта <имя_пользователя, ссылка_на_соединение>.private final Logger logger= логировщик, используемый на сервере в целом.
public Dispatcher(Server host)инициализирует пустой реестр, запоминает ссылку на сервер и на логер.
public boolean addUser(String userName, Connection connection)регистрирует участника; возвращает true, если успешно добавлен (т.е. если имя является допустимым, отсутствовало в реестре, а теперь появилось). Логирует успех или отказ регистрации.public Set<String> getUsers()сообщает набор участников.public Set<String> getUsersBut(String aUser)сообщает набор участников за исключением одного.public Connection getConnectionForUser(String user)даёт ссылку на соединение, ассоциированное с участником.public String getUserForConnection(Connection connection)даёт имя участника, ассоциированного с соединением.
private void send(Message message, String username)вкладывает сообщение в метод.sendMessage()соединения, найденного по имени пользователя. Обнаружив, что соединение с клиентом закрыто, вызывает процедуру его отключения.private void send(Message msg, boolean toLog)надстройка над.send(Message, String), отправляет сообщение тому пользователю, который указан в сообщении как получатель. Если получатель не указан, не делает ничего. Если toLog равно true, логирует сообщение как отправленное.private void send(Message msg)эквивалентно.send(msg, true).private void forward(Message message)если получатель не указан (т.е. публичное), рассылает сообщение всем подключённым участникам, кроме его отправителя. Если сообщение частное, отправляет его адресату. Логирует как переданное.private void broadcast(Message message)рассылает сообщение всем подключённым участникам, при этом явно заполнив в нём перед отправкой поле получателя. Логирует как отправленное.private void castWithExclusive(Message generalMessage, String exclusiveOne, Message specialMessage)рассылает первое сообщение всем подключённым участникам, кроме указанного, а ему второе сообщение. Явно проставляет всех получателей, логирует оба сообщения как отправленные.
public void operateOn(Message gotMessage, Connection source)→ метод взаимодействия Диспетчера со входящим сообщением - Соединение передаёт сюда сообщение и ссылку на себя. Получение серверных сообщений не предполагается. Получив передаваемое сообщение, отдаёт его в метод.forward(). ПолучивLIST_REQUEST, вызывает метод.sendUserList()с именем отправителя. ПолучивREG_REQUEST, вызывает метод.changeName()с именем отправителя и ссылкой на соединение-источник. ПолучивEXIT_REQUEST, вызывает метод.goodbyeUser()с именем отправителя. ПолучивSHUT_REQUEST, вызывает у соединения-источника процедуру.getShut().public void closeSession()вызывает для каждого пользователя из реестра процедуру.disconnect()с текстом уведомления о завершении работы.public void greetUser(String greeted)высылает новоподключённому участнику приветственное сообщение с информацией о чате, а остальным подключённым участникам сообщение, уведомляющее о подключении участника.public void goodbyeUser(String username)запускает для указанного участника процедуру.disconnect()с прощальным текстом. Если она вернулаtrue, рассылает подключённым участникам сообщение, уведомляющее об отключении участника.private void changeName(String newName, Connection connection)добавляет в реестр новое имя, ассоциируя его со ссылкой на соединение. Если добавление проходит успешно, удаляет из реестра имя, которое было ключом к данному соединению до того, и рассылает всем участникам сообщение, уведомляющее о смене имени одним из них. Если добавление не проходит успешно (т.е. если предлагаемое имя не является допустимым либо уже зарегистрировано для какого-то соединения), отсылает об этом уведомление тому, кто присылал запрос, (на старое имя).private boolean disconnect(String username, String farewell)отсылает указанному пользователю стоп-сообщение с указанным текстом, после этого закрывает ассоциированное с ним соединение, удаляет его из реестра и возвращаетtrue. Если что-то из этого обломилось ошибкой, логирует её и возвращаетfalse.private void sendUserList(String requesting)высылает указанному пользователю сообщение со списком зарегистрированных в данный момент в реестре участников.
private String welcomeText(String greeted)возвращает текст, приветствующий указанного пользователя, сообщающий актуальный сетевой адрес чата и перечисляющий пользовательские команды и подключённых участников.private String getUserListing()возвращает текст, сообщающий количество подключённых и их имена.
Является исполняемой обёрткой для сокета, способной принимать и отправлять сообщения класса Сообщение,
а также самостоятельно проводить некоторые интерактивные процедуры взаимодействия с клиентом.
Реализует интерфейсы Runnable и AutoCloseable, запуск обработки сокета в пуле соответствует методу .run(),
а закрытие соединения - методу .close(), также метод isClosed() соответствует тождественному методу сокета.
Работа соединения осуществляется в одном из двух режимов: локальном, когда новое входящее сообщение
обрабатывается локальным методом, и глобальном, когда оно передаётся на обработку Диспетчеру.
В локальном режиме соединение находится в начале работы при исполнении метода .requesterUser(),
а также при исполнении метода обработки запроса на остановку сервера - .getShut().
private final Server hostссылка на Сервер, установивший это Соединение.private final Dispatcher dispatcherссылка на Диспетчер сервера.private final Socket socketсокетное соединение, обёрткой для которого служит этот объектprivate final Logger loggerссылка на Логировщик сервера.private ObjectInputStream messageReceiverвходящий объектный поток от сокета.private ObjectOutputStream messageSenderисходящий объектный поток на сокет.private boolean localMode = trueпоказатель, находится ли Соединение в локальном режиме.
public Connection(Server host, Socket socket)устанавливает ссылку на обслуживаемое экземпляром сокетное соединение и на сервер, также разрешает ссылки на Диспетчер и Логировщик серверной стороны.
@Override public void run()→ жизненный цикл соединения:- получение входящего и исходящего потоков из сокета;
- запуск процедуры регистрации пользователя;
- пока сокет не закрыт, проверяет, находится ли в глобальном режиме: если да, то получает очередное сообщение и передаёт его Диспетчеру; если нет, то очередное сообщение принимает и обрабатывает один из локальных методов.
- обнаружив, что соединение закрыто, завершает исполнение.
private void registerUser()процедура регистрации нового участника: отсылает в новоустановленное соединение пробное сообщение, считывает имя отправителя из полученного сообщения и пытается зарегистрировать его в диспетчере. Как только эта попытка венчается успехом, выходит из локального режима и говорит диспетчеру провести обряд приветствия нового участника. Но пока попытки зарегистрировать имя не успешны, шлёт регистрирующемуся об этом уведомления. Логирует отсылаемые сообщения (входящие логируются на уровне метода.receiveMessage(), так как ожидаются только запросные сообщения).public void sendMessage(Message message) throws IOExceptionзаписывает сообщение в исходящий поток. Метод открытый, так что используется другими классами.private Message receiveMessage() throws IOException, ClassNotFoundExceptionдожидается из входящего потока новое входящее сообщение и возвращает его. Если оно является запросом, также логирует его.public void getShut()процедура аутентификации для управления сервером: переходит в локальный режим, уточняет у диспетчера зарегистрированное имя для данного соединения, генерирует, отправляет и логирует серверное сообщение с предложением прислать пароль; получает содержимое нового входящего сообщения как массив байтов и логирует фиктивное сообщение с замаскированным содержимым; затем возвращается в глобальный режим и вызывает у сервера метод.stopServer(), передавая в него полученный массив.
private void setLocalMode()иprivate void setGlobalMode()устанавливают флагlocalModeвtrueиfalseсоответственно.@Override public void close() throws Exceptionиpublic boolean isClosed()соответствуют аналогичным методам обёрнутого сокета.@Override public String toString()используется для строкового представления экземпляра.
Сеанс работы клиента с хабом завершается в трёх случаях: клиент прислал запрос на отключение, соединение оказалось по каким-либо причинам потеряно, либо сервер завершает работу. В любом случае корректное завершение сеанса сопровождается удалением имени из реестра подключённых. При отключении клиента по своей инициативе либо из-за разрыва соединения уведомление об этом получают оставшиеся подключёнными пользователи. Также подключённые пользователи получают уведомление при завершении работы сервера.
Остановка сервера в данной реализации возможна только посредством отправки ему соответствующего запроса с последующее отправкой пароля, соответствующего заданному при инициализации сервера.
Клиентская часть приложения состоит из двух классов:
- класс
Client- Клиент, устанавливающий соединение до Сервера и отправляющий ему сообщения, иже пользователь набирает в консоли; - класс
Receiver- Приёмник, отдельный поток, слушающий, обрабатывающий и отображающий пользователю в консоль сообщения от Сервера.