Skip to content

Instantly share code, notes, and snippets.

@Shtaba09
Created November 19, 2018 22:04
Show Gist options
  • Save Shtaba09/3b20be40b4f04bbc2791cf0c22ee8b8f to your computer and use it in GitHub Desktop.
Save Shtaba09/3b20be40b4f04bbc2791cf0c22ee8b8f to your computer and use it in GitHub Desktop.
Мой первый стоковый чат! Даст бог зделаю улучшения
package com.javarush.task.task30.task3008.client;
import com.javarush.task.task30.task3008.ConsoleHelper;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class BotClient extends Client {
public static void main (String[] args){
BotClient client = new BotClient();
client.run();
}
@Override
protected SocketThread getSocketThread() {
return new BotSocketThread();
}
@Override
protected boolean shouldSendTextFromConsole() {
return false;
}
@Override
protected String getUserName() {
return "date_bot_"+(int)(Math.random()*100);
}
public class BotSocketThread extends SocketThread{
@Override
protected void clientMainLoop() throws IOException, ClassNotFoundException {
sendTextMessage("Привет чатику. Я бот. Понимаю команды: дата, день, месяц, год, время, час, минуты, секунды.");
super.clientMainLoop();
}
@Override
protected void processIncomingMessage(String message) {
ConsoleHelper.writeMessage(message);
if (message != null && message.contains(": ")){
String[] messmas = message.split(": ");
String infoFor = "Информация для "+ messmas[0]+": ";
Date current = Calendar.getInstance().getTime();
if(messmas[1].equals("дата")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("d.MM.YYYY");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("день")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("d");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("месяц")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("MMMM");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("год")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("YYYY");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("время")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("H:mm:ss");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("час")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("H");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("минуты")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("m");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
else if(messmas[1].equals("секунды")){
SimpleDateFormat simpleDateFormat = new SimpleDateFormat("s");
sendTextMessage(infoFor+simpleDateFormat.format(current));}
}}
}
}
package com.javarush.task.task30.task3008.client;
import com.javarush.task.task30.task3008.Connection;
import com.javarush.task.task30.task3008.ConsoleHelper;
import com.javarush.task.task30.task3008.Message;
import com.javarush.task.task30.task3008.MessageType;
import java.io.IOException;
import java.net.Socket;
public class Client {
protected Connection connection;
private volatile boolean clientConnected=false;
public static void main (String[] args){
Client client =new Client();
client.run();
}
public void run(){
Client.SocketThread socketThread =getSocketThread();
socketThread.setDaemon(true);
socketThread.start();
synchronized (this){
try {
wait();
} catch (InterruptedException e) {
ConsoleHelper.writeMessage("Error wait soketThread");
}
}
if (clientConnected){ConsoleHelper.writeMessage("Соединение установлено. Для выхода наберите команду 'exit'.");}
else {ConsoleHelper.writeMessage("Произошла ошибка во время работы клиента.");}
while (clientConnected){
String message = ConsoleHelper.readString();
if (message.equals("exit")){break;}
if (shouldSendTextFromConsole()){
sendTextMessage(message);
}
}
}
protected String getServerAddress(){
ConsoleHelper.writeMessage("Please enter host name or IP-adress");
String adress = ConsoleHelper.readString();
//if(adress.equals("localhost")){adress="127.0.0.1";}
return adress;
}
protected int getServerPort(){
ConsoleHelper.writeMessage("Please enter port");
int port = ConsoleHelper.readInt();
return port;
}
protected String getUserName(){
ConsoleHelper.writeMessage("Please enter name");
String name = ConsoleHelper.readString();
return name;
}
protected boolean shouldSendTextFromConsole(){
return true;
}
protected SocketThread getSocketThread(){
return new SocketThread();
}
protected void sendTextMessage(String text){
try {
connection.send(new Message(MessageType.TEXT,text));
} catch (IOException e) {
ConsoleHelper.writeMessage("Error send message!");
clientConnected=false;
}
}
public class SocketThread extends Thread {
@Override
public void run() {
String adress =getServerAddress();
int port = getServerPort();
try {
Socket socket = new Socket(adress,port);
connection= new Connection(socket);
clientHandshake();
clientMainLoop();
} catch (IOException | ClassNotFoundException e) {
notifyConnectionStatusChanged(false);
}
}
protected void processIncomingMessage(String message){
ConsoleHelper.writeMessage(message);
}
protected void informAboutAddingNewUser(String userName){
ConsoleHelper.writeMessage("Участник "+userName+" присоеденился к чату!");
}
protected void informAboutDeletingNewUser(String userName){
ConsoleHelper.writeMessage("Участник "+userName+" покинул чат.");
}
protected void notifyConnectionStatusChanged(boolean clientConnected){
Client.this.clientConnected=clientConnected;
synchronized (Client.this){
Client.this.notify();
}
}
protected void clientHandshake() throws IOException, ClassNotFoundException{
while (true){
Message message =connection.receive();
if(message.getType()==MessageType.NAME_REQUEST){String name=getUserName();
connection.send(new Message(MessageType.USER_NAME,name));
}else if (message.getType()==MessageType.NAME_ACCEPTED){notifyConnectionStatusChanged(true);
break;
} else{throw new IOException("Unexpected MessageType");}
}
}
protected void clientMainLoop() throws IOException, ClassNotFoundException{
while (true){
Message message = connection.receive();
if (message.getType()==MessageType.TEXT){processIncomingMessage(message.getData());}
else if(message.getType()==MessageType.USER_ADDED){informAboutAddingNewUser(message.getData());}
else if(message.getType()==MessageType.USER_REMOVED){informAboutDeletingNewUser(message.getData());}
else {throw new IOException("Unexpected MessageType");}
}
}
}
}
package com.javarush.task.task30.task3008.client;
public class ClientGuiController extends Client {
private ClientGuiModel model = new ClientGuiModel();
private ClientGuiView view = new ClientGuiView(this);
public static void main(String[] args){
ClientGuiController clientGuiController=new ClientGuiController();
clientGuiController.run();
}
public ClientGuiModel getModel(){
return model;
}
@Override
public void run() {
SocketThread socketThread = getSocketThread();
socketThread.run();
}
@Override
protected String getServerAddress() {
return view.getServerAddress();
}
@Override
protected int getServerPort() {
return view.getServerPort();
}
@Override
protected String getUserName() {
return view.getUserName();
}
@Override
protected SocketThread getSocketThread() {
return new GuiSocketThread();
}
public class GuiSocketThread extends SocketThread{
@Override
protected void processIncomingMessage(String message) {
model.setNewMessage(message);
view.refreshMessages();
}
@Override
protected void informAboutAddingNewUser(String userName) {
model.addUser(userName);
view.refreshUsers();
}
@Override
protected void informAboutDeletingNewUser(String userName) {
model.deleteUser(userName);
view.refreshUsers();
}
@Override
protected void notifyConnectionStatusChanged(boolean clientConnected) {
view.notifyConnectionStatusChanged(clientConnected);
}
}
}
package com.javarush.task.task30.task3008.client;
import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;
public class ClientGuiModel {
private final Set<String> allUserNames = new TreeSet<>();
private String newMessage;
public String getNewMessage() {
return newMessage;
}
public void setNewMessage(String newMessage) {
this.newMessage = newMessage;
}
public Set<String> getAllUserNames() {
return Collections.unmodifiableSet(allUserNames);
}
public void addUser(String newUserName){
allUserNames.add(newUserName);
}
public void deleteUser(String userName){
allUserNames.remove(userName);
}
}
package com.javarush.task.task30.task3008.client;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
public class ClientGuiView {
private final ClientGuiController controller;
private JFrame frame = new JFrame("Чат");
private JTextField textField = new JTextField(50);
private JTextArea messages = new JTextArea(10, 50);
private JTextArea users = new JTextArea(10, 10);
public ClientGuiView(ClientGuiController controller) {
this.controller = controller;
initView();
}
private void initView() {
textField.setEditable(false);
messages.setEditable(false);
users.setEditable(false);
frame.getContentPane().add(textField, BorderLayout.NORTH);
frame.getContentPane().add(new JScrollPane(messages), BorderLayout.WEST);
frame.getContentPane().add(new JScrollPane(users), BorderLayout.EAST);
frame.pack();
frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
frame.setVisible(true);
textField.addActionListener(new ActionListener() {
public void actionPerformed(ActionEvent e) {
controller.sendTextMessage(textField.getText());
textField.setText("");
}
});
}
public String getServerAddress() {
return JOptionPane.showInputDialog(
frame,
"Введите адрес сервера:",
"Конфигурация клиента",
JOptionPane.QUESTION_MESSAGE);
}
public int getServerPort() {
while (true) {
String port = JOptionPane.showInputDialog(
frame,
"Введите порт сервера:",
"Конфигурация клиента",
JOptionPane.QUESTION_MESSAGE);
try {
return Integer.parseInt(port.trim());
} catch (Exception e) {
JOptionPane.showMessageDialog(
frame,
"Был введен некорректный порт сервера. Попробуйте еще раз.",
"Конфигурация клиента",
JOptionPane.ERROR_MESSAGE);
}
}
}
public String getUserName() {
return JOptionPane.showInputDialog(
frame,
"Введите ваше имя:",
"Конфигурация клиента",
JOptionPane.QUESTION_MESSAGE);
}
public void notifyConnectionStatusChanged(boolean clientConnected) {
textField.setEditable(clientConnected);
if (clientConnected) {
JOptionPane.showMessageDialog(
frame,
"Соединение с сервером установлено",
"Чат",
JOptionPane.INFORMATION_MESSAGE);
} else {
JOptionPane.showMessageDialog(
frame,
"Клиент не подключен к серверу",
"Чат",
JOptionPane.ERROR_MESSAGE);
}
}
public void refreshMessages() {
messages.append(controller.getModel().getNewMessage() + "\n");
}
public void refreshUsers() {
ClientGuiModel model = controller.getModel();
StringBuilder sb = new StringBuilder();
for (String userName : model.getAllUserNames()) {
sb.append(userName).append("\n");
}
users.setText(sb.toString());
}
}
package com.javarush.task.task30.task3008;
import java.io.Closeable;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.net.Socket;
import java.net.SocketAddress;
public class Connection implements Closeable {
private final Socket socket;
private final ObjectOutputStream out;
private final ObjectInputStream in;
public Connection(Socket socket) throws IOException {
this.socket = socket;
this.out= new ObjectOutputStream(socket.getOutputStream());
this.in= new ObjectInputStream(socket.getInputStream());
}
public void send(Message message) throws IOException{
synchronized (out){
out.writeObject(message);
}}
public Message receive() throws IOException{
Message message=null;
synchronized (in){
try {
message = (Message) in.readObject();
} catch (ClassNotFoundException e) {
e.printStackTrace();
}}
return message;
}
public SocketAddress getRemoteSocketAddress(){
return socket.getRemoteSocketAddress();
}
@Override
public void close() throws IOException{
out.close();
in.close();
socket.close();
}
}
package com.javarush.task.task30.task3008;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
public class ConsoleHelper {
private static BufferedReader reader=new BufferedReader(new InputStreamReader(System.in));
public static void writeMessage(String message){
System.out.println(message);
}
public static String readString() {
String line= null;
while (true){
try {
line = reader.readLine();
break;
} catch (IOException e ) {
System.out.println("Uncorected line! Please rewrite message");
}}
return line;
}
public static int readInt(){
int line=0;
while (true){
try {
line=Integer.parseInt(readString());
break;
}catch (NumberFormatException e){
System.out.println("Uncorected line! Please rewrite message");
}}
return line;
}
}
package com.javarush.task.task30.task3008;
import java.io.Serializable;
public class Message implements Serializable {
private final MessageType type;
private final String data;
public Message(MessageType type, String data) {
this.data = data;
this.type = type;
}
public Message(MessageType type) {
this.type = type;
this.data = null;
}
public MessageType getType() {
return type;
}
public String getData() {
return data;
}
}
package com.javarush.task.task30.task3008;
public enum MessageType {
NAME_REQUEST,
USER_NAME,
NAME_ACCEPTED,
TEXT,
USER_ADDED,
USER_REMOVED;
}
package com.javarush.task.task30.task3008;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Server {
private static Map<String, Connection> connectionMap = new ConcurrentHashMap<>();
public static void main(String[] args){
int port = ConsoleHelper.readInt();
try (ServerSocket socket = new ServerSocket(port)){
ConsoleHelper.writeMessage("Server is starting");
while (!socket.isClosed()){
try {
new Handler(socket.accept()).start();
}catch (Exception e){
ConsoleHelper.writeMessage(e.getMessage());
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
private static class Handler extends Thread{
Socket socket;
public Handler(Socket socket){
this.socket=socket;
}
@Override
public void run() {
ConsoleHelper.writeMessage("Connected "+socket.getRemoteSocketAddress());
try {
Connection connection = new Connection(socket);
String user = serverHandshake(connection);
sendBroadcastMessage(new Message(MessageType.USER_ADDED,user));
sendListOfUsers(connection, user);
serverMainLoop(connection, user);
if(!user.isEmpty()){
connectionMap.remove(user);
sendBroadcastMessage(new Message(MessageType.USER_REMOVED,user));
}
} catch (IOException | ClassNotFoundException e) {
ConsoleHelper.writeMessage("Error! Connection do not created");
try {
socket.close();
} catch (IOException e1) {
ConsoleHelper.writeMessage("Soket do not closed");
}
}
}
private String serverHandshake(Connection connection) throws IOException, ClassNotFoundException{
while (true){
connection.send(new Message(MessageType.NAME_REQUEST));
Message answer = connection.receive();
if (answer.getType() == MessageType.USER_NAME){
if (!answer.getData().isEmpty()){
if (!connectionMap.containsKey(answer.getData())){
connectionMap.put(answer.getData(), connection);
connection.send(new Message(MessageType.NAME_ACCEPTED));
return answer.getData();
}
}
}
}
}
private void sendListOfUsers(Connection connection, String userName) throws IOException {
for (Map.Entry<String,Connection> pair : connectionMap.entrySet()){
if(!pair.getKey().equals(userName)){
connection.send(new Message(MessageType.USER_ADDED,pair.getKey()));
}
}
}
private void serverMainLoop(Connection connection, String userName) throws IOException, ClassNotFoundException{
while (true){
Message message = connection.receive();
if(message!=null && message.getType()==MessageType.TEXT){sendBroadcastMessage(new Message(MessageType.TEXT,userName+": "+message.getData()));}
else {ConsoleHelper.writeMessage("Error ");}
}
}
}
public static void sendBroadcastMessage(Message message){
for (Map.Entry<String,Connection> pair : connectionMap.entrySet()){
Connection connection = pair.getValue();
try {
connection.send(message);
} catch (IOException e) {
ConsoleHelper.writeMessage("Message do not sending.");
}
}
}
}
taskKey="com.javarush.task.task30.task3008.big22"
Чат (22)
Итак, подведем итог:
• Ты написал сервер для обмена текстовыми сообщениями.
• Ты написал консольный клиент, который умеет подключаться к серверу и
обмениваться сообщениями с другими участниками.
• Ты написал бот клиента, который может принимать запросы и отправлять данные о
текущей дате и времени.
• Ты написал клиента для чата с графическим интерфейсом.
Что можно добавить или улучшить:
• Можно добавить поддержку приватных сообщений (когда сообщение отправляется не
всем, а какому-то конкретному участнику).
• Можно расширить возможности бота, попробовать научить его отвечать на
простейшие вопросы или время от времени отправлять шутки.
• Добавить возможность пересылки файлов между пользователями.
• Добавить контекстное меню в графический клиент, например, для отправки
приватного сообщения кому-то из списка участников.
• Добавить раскраску сообщений в графическом клиенте в зависимости от отправителя.
• Добавить блокировку сервером участников за что-либо, например, ненормативную
лексику в сообщениях.
• Добавить еще миллион фич и полезностей!
Ты научился:
• Работать с сокетами.
• Пользоваться сериализацией и десериализацией.
• Создавать многопоточные приложения, синхронизировать их, применять модификатор
volatile, пользоваться классами из библиотеки java.util.concurrent.
• Применять паттерн MVC.
• Использовать внутренние и вложенные классы.
• Работать с библиотекой Swing.
• Применять классы Calendar и SimpleDateFormat.
Так держать!
Требования:
1. Поздравляю, чат готов!
Чат (21)
У меня есть отличнейшая новость для тебя. Компонент представление (View) уже готов. Я
добавил класс ClientGuiView. Он использует библиотеку javax.swing. Ты должен как следует
разобраться в каждой строчке этого класса. Если тебе все понятно – это замечательно, если
нет – обязательно найди ответы на свои вопросы с помощью дебага, документации или
поиска в Интернет.
Осталось написать компонент контроллер (Controller):
1) Создай класс ClientGuiController унаследованный от Client.
2) Создай и инициализируй поле, отвечающее за модель ClientGuiModel
model.
3) Создай и инициализируй поле, отвечающее за представление ClientGuiView
view. Подумай, что нужно передать в конструктор при инициализации объекта.
4) Добавь внутренний класс GuiSocketThread унаследованный от SocketThread.
Класс GuiSocketThread должен быть публичным. В нем переопредели следующие
методы:
а) void processIncomingMessage(String message) – должен устанавливать новое
сообщение у модели и вызывать обновление вывода сообщений у
представления.
б) void informAboutAddingNewUser(String userName) – должен добавлять нового
пользователя в модель и вызывать обновление вывода пользователей у
отображения.
в) void informAboutDeletingNewUser(String userName) – должен удалять
пользователя из модели и вызывать обновление вывода пользователей у
отображения.
г) void notifyConnectionStatusChanged(boolean clientConnected) – должен вызывать
аналогичный метод у представления.
5) Переопредели методы в классе ClientGuiController:
а) SocketThread getSocketThread() – должен создавать и возвращать объект типа
GuiSocketThread.
б) void run() – должен получать объект SocketThread через метод getSocketThread()
и вызывать у него метод run(). Разберись, почему нет необходимости вызывать
метод run в отдельном потоке, как мы это делали для консольного клиента.
в) getServerAddress(), getServerPort(),getUserName(). Они должны вызывать
одноименные методы из представления (view).
6) Реализуй метод ClientGuiModel getModel(), который должен возвращать модель.
7) Реализуй метод main(), который должен создавать новый объект
ClientGuiController и вызывать у него метод run().
Запусти клиента с графическим окном, нескольких консольных клиентов и убедись, что
все работает корректно.
Чат (20)
Консольный клиент мы уже реализовали, чат бота тоже сделали, почему бы не сделать
клиента с графическим интерфейсом? Он будет так же работать с нашим сервером, но
иметь графическое окно, кнопки и т.д.
Итак, приступим. При написании графического клиента будет очень уместно воспользоваться
паттерном MVC (Model-View-Controller). Ты уже должен был с ним сталкиваться, если необходимо,
освежи свои знания про MVC с помощью Интернет. В нашей задаче самая простая реализация
будет у класса, отвечающего за модель (Model).
Давай напишем его:
1) Создай класс ClientGuiModel в пакете client. Все классы клиента должны быть созданы в этом
пакете.
2) Добавь в него множество(set) строк в качестве final поля allUserNames.
В нем будет храниться список всех участников чата. Проинициализируй его.
3) Добавь поле String newMessage, в котором будет храниться новое сообщение,
которое получил клиент.
4) Добавь геттер для allUserNames, запретив модифицировать возвращенное
множество. Разберись, как это можно сделать с помощью метода класса Collections.
5) Добавь сеттер и геттер для поля newMessage.
6) Добавь метод void addUser(String newUserName), который должен добавлять
имя участника во множество, хранящее всех участников.
7) Добавь метод void deleteUser(String userName), который будет удалять имя
участника из множества.
Чат (19)
Сейчас будем реализовывать класс BotSocketThread, вернее переопределять некоторые
его методы, весь основной функционал он уже унаследовал от SocketThread.
1) Переопредели метод clientMainLoop():
а) С помощью метода sendTextMessage() отправь сообщение с текстом
"Привет чатику. Я бот. Понимаю команды: дата, день, месяц, год, время, час, минуты, секунды."
б) Вызови реализацию clientMainLoop() родительского класса.
2) Переопредели метод processIncomingMessage(String message). Он должен
следующим образом обрабатывать входящие сообщения:
а) Вывести в консоль текст полученного сообщения message.
б) Получить из message имя отправителя и текст сообщения. Они разделены ": ".
в) Отправить ответ в зависимости от текста принятого сообщения. Если текст
сообщения:
"дата" – отправить сообщение содержащее текущую дату в формате "d.MM.YYYY";
"день" – в формате"d";
"месяц" - "MMMM";
"год" - "YYYY";
"время" - "H:mm:ss";
"час" - "H";
"минуты" - "m";
"секунды" - "s".
Указанный выше формат используй для создания объекта SimpleDateFormat. Для
получения текущей даты необходимо использовать класс Calendar и метод
getTime().
Ответ должен содержать имя клиента, который прислал запрос и ожидает ответ,
например, если Боб отправил запрос "время", мы должны отправить ответ
"Информация для Боб: 12:30:47".
Наш бот готов. Запусти сервер, запусти бота, обычного клиента и убедись, что все работает правильно.
Помни, что message бывают разных типов и не всегда содержат ":"
Чат (18)
Иногда бывают моменты, что не находится достойного собеседника. Не общаться же с
самим собой :). Давай напишем бота, который будет представлять собой клиента, который
автоматически будет отвечать на некоторые команды. Проще всего реализовать бота,
который сможет отправлять текущее время или дату, когда его кто-то об этом попросит.
С него и начнем:
1) Создай новый класс BotClient в пакете client. Он должен быть унаследован от
Client.
2) В классе BotClient создай внутренний класс BotSocketThread унаследованный от
SocketThread. Класс BotSocketThread должен быть публичным.
3) Переопредели методы:
а) getSocketThread(). Он должен создавать и возвращать объект класса
BotSocketThread.
б) shouldSendTextFromConsole(). Он должен всегда возвращать false. Мы не хотим,
чтобы бот отправлял текст введенный в консоль.
в) getUserName(), метод должен генерировать новое имя бота, например:
date_bot_X, где X – любое число от 0 до 99. Для генерации X используй метод Math.random().
4) Добавь метод main. Он должен создавать новый объект BotClient и вызывать у
него метод run().
Чат (17)
Последний, но самый главный метод класса SocketThread – это метод void run(). Добавь
его. Его реализация с учетом уже созданных методов выглядит очень просто.
Давай напишем ее:
1) Запроси адрес и порт сервера с помощью методов getServerAddress() и
getServerPort().
2) Создай новый объект класса java.net.Socket, используя данные, полученные в
предыдущем пункте.
3) Создай объект класса Connection, используя сокет из п.17.2.
4) Вызови метод, реализующий "рукопожатие" клиента с сервером
(clientHandshake()).
5) Вызови метод, реализующий основной цикл обработки сообщений сервера.
6) При возникновении исключений IOException или ClassNotFoundException
сообщи главному потоку о проблеме, используя notifyConnectionStatusChanged и false
в качестве параметра.
Клиент готов, можешь запустить сервер, несколько клиентов и проверить как все работает.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment