Чат в терминале на Rust
Эта статья — туториал по написанию небольшого чат сервиса (серверное и клиентское приложения) на Rust, используя функционал TCP сокетов из стандартной библиотеки Rust. Сам чат для пользователя будет выглядеть, как приложение в терминале. Полный код приложений есть в гитхабе.

Ссылка на статью на хабре – https://habr.com/ru/articles/728870/
Начало
Много объяснений будет записано в качестве комментариев к коду.
У нас будет 2 приложения: сервер, который будет принимать сообщения и раздавать их всем пользователям, подключённым к чату, и клиент, который будет показывать юзеру сообщения полученные от сервера и отправлять серверу сообщения от юзера. Создать шаблон для этих приложений можно через cargo new <name>. После этого нашим приложениям надо прописать базовые состояния и типы, которые они будут использовать на протяжении всей свой работы.
Для сервера начнём со структуры Settings. Она будет парсить и сохранять аргументы пользователя при запуске програм мы. Для парсинга будет использоваться clap.
Код структуры Settings на сервере (Файл server/src/settings.rs)
// Импортирование нужного трейта из clap
use clap::Parser;
// Объявление того, что будет парсится в кач естве аргументов.
// С помощью derive макроса можно навесить на структуру макрос импортированного
// трейта в качестве атрибута этой структуры. В нашем случае таким образом
// будет сгенерирован нужный impl c функционалом Parser'а для структуры Args.
#[derive(Parser)]
pub struct Args {
// Макрос arg так же импортируется из clap автоматически
// и позволяет объявить поле аргументом и задать ему нужные свойства.
// short означает, что аргумент можно будет вписать сокращённо
// вот так "-p 8080". long, что можно использовать
// полное название "--port 8080". А help это просто вспомогательный
// текст, который будет показываться при запуске приложения с --help
#[arg(short, long, help = "Port that the server will serve")]
pub port: u16,
}
// Трейт Debug позволяет удобно выводить структуру через print в консоль, а
// Clone добавляет функционал для клонирования инстансов структуры.
// В derive мы опять передаём макросы этих трейтов чтобы они сгенерировали нам
// impl'ы с реализациями Debug и Clone, чтобы вручную не писать это.
#[derive(Debug, Clone)]
pub struct Settings {
pub port: u16,
}
// Внутри impl прописываются методы структуры
impl Settings {
pub fn new() -> Settings {
// используем метод от трейта Parser
let args = Args::parse();
// Создаём инстанс структуры Settings и возвращаем его
Settings {
port: args.port,
}
}
}
Добавим создание объекта Settings в наш main.rs. После этого при запуске приложения будут запрашиваться аргументы, а нашем случае — порт сервера.
Код server/src/main.rs
// anyhow это небольшая библиотека, которая добавляет enum Result,
// почти аналогичный Result'у из std, с единственным отличием, что этот
// может принимать любую ошибку
use anyhow::Result;
// Импортирование нашей новой структуры
use settings::Settings;
// Обязательное указание модуля, иначе файл виден не будет
mod settings;
fn main() -> Result<()> {
// Создание инстанса нстроек
let settings = Settings::new();
// возвращение Result::Ok() значения
Ok(())
}
Следующее, что нужно сделать это структуру состояния (State) нашего серверного приложения. Так как сервер, будет работать сразу в несколько потоков, то и состояние должно поддерживать многопоточность. Для этого внутри структуры данные завёрнуты в Arc и Mutex, подробнее в коде.
Код структуры State на сервере (Файл server/src/state.rs)
use std::{
// Arc (Atomic Reference Counter) это smart pointer, который реализует
// множественное владение переменной. То-есть, грубо говоря, данные на
// которые указывает Arc не исчезнут пока есть
// хотя бы один клон этого Arc'а. По сути, то же самое делает и
// Rc (Reference Counter), но Rc не поддерживает многопоточность.
sync::Arc,
collections::HashMap
};
// Это аналог Mutex'а из стандартной библиотеки, но работающий намного быстрее.
// Сам Mutex это структура, которая блокируется для доступа из других потоков,
// если в одном из них она уже используется. И соответственно после использования
// она становится доступна для других потоков. Это нужно для того чтобы не было
// рассинхрона данных между потоками.
use parking_lot::{Mutex, MutexGuard};
use crate::settings::Settings;
// Каждый юзер после подключения будет записываться в стейт,
// структура UserData описывает, что будет хранить в себе запись
// о подключенном юзере.
#[derive(Debug, Clone)]
pub struct UserData {
// Ip адрес подключённого пользователя + его сокета
pub address: String,
}
#[derive(Debug, Clone)]
pub struct StateData {
// Настройки приложения, которые мы описали ранее
pub settings: Settings,
// HashMap'а хранящая данные о подключённых юзерах, где ключ это никнейм,
// значение это UserData
pub users: HashMap<String, UserData>,
}
// Arc, как я писал выше реализует множественно влад ение данными, но он не
// позволяет эти данные менять. Для этого чтобы это было возможно и безопасно мы
// дополнительно оборачиваем StateData в Mutex.
pub struct State(Arc<Mutex<StateData>>);
impl State {
pub fn new(settings: Settings) -> State {
State(
Arc::new(Mutex::new(StateData {
settings,
users: HashMap::new()
}))
)
}
// Метод для упрощения доступа к данным. Он блокирует Mutex для работы с
// данными только в текущем потоке. И возвращает MutexGuard. Пока MutexGuard
// жив другие потоки не смогут заблокировать данные для себя.
pub fn get(&self) -> MutexGuard<StateData> {
self.0.lock()
}
}
// Реализация трейта Clone для State. Просто повесить макрос трейта Clone
// через derive не получится, потому что копировать нужно внутренний Arc.
// Поэтому необходимые для Clone методы реализуем вручную.
impl Clone for State {
fn clone(&self) -> Self {
State(Arc::clone(&self.0))
}
fn clone_from(&mut self, source: &Self) {
*self = source.clone();
}
}
Теперь так же перенесём State в нашу main функцию.
Обновлённый код функции main для сервера (Файл server/src/main.rs)
use anyhow::Result;
use settings::Settings;
use state::State; // +
mod settings;
mod state; // +
fn main() -> Result<()> {
let settings = Settings::new();
let state = State::new(settings)); // +
Ok(())
}
Для серверного приложения состояние и базовые параметры готовы, тоже самое нужно прописать для клиента.
Код структуры Settings для клиента (Файл client/src/settings.rs)
use clap::Parser;
#[derive(Parser)]
pub struct Args {
// Адрес сервера с портом, к которому будет производится подключение
#[arg(short, long, help = "Server address")]
pub address: String,
}
#[derive(Debug, Clone)]
pub struct Settings {
pub server_address: String,
}
impl Settings {
pub fn new() -> Settings {
let args = Args::parse();
Settings {
server_address: args.address
}
}
}
State для клиента немного отличается, но суть та же. Структура, чтобы хранить состояние приложения, с возможностью раздачи его на несколько потоков.
Код структуры State для клиента (Файл client/src/state.rs)
use std::{
sync::{
// mpsc нужно для передачи сообщений по каналу между несколькими потоками.
// В нашем случае будут два потока (главный и созданный), один из которых
// будет передавать второму сигналы по каналу mpsc.
mpsc::{
Sender,
Receiver,
self
},
Arc
},
io::{
self,
// BufReader будем использовать для чтения данных с tcp сокета.
// Он работает по такому принципу: делает редкие, но объемные read
// запросы по файл дескриптору и далее мы можем удобно, что он прочитал.
// Для чтения строк из tcp сокета это очень хорошо подходит.
BufReader,
// Два трейта. Один для чтения из BufReader'а, другой для записи в файл
// (в нашем случае в сокет).
BufRead,
Write
}
};
use parking_lot::Mutex;
pub struct State {
// Ник, который юзер введёт при запуске приложения
pub username: String,
// Принимающая часть канала mpsc. В качестве типа передаваемых данных
// указан unit (пустой tuple), так как нам нужен будет сам факт наличия
// нового сообщения, его внутренности интересовать не будут.
// Указывается как Option, потому что в будет передана другому потоку и
// после этого доступна не будет и тут будет храниться None.
pub chat_reload_receiver: Option<Receiver<()>>,
// Часть канала mpsc, которая отправляет информацию принимающему потоку.
pub chat_reload_sender: Sender<()>,
// В user_input'е будет лежать текущий ввод пользователя. Пример:
// юзер пишет "привет", но не отправляет его в чат. "приве" лежит
// в user_input'е. Обычно такая реализация не требуется, но у нас часто будет
// полностью перерисовываться чат, и при этом будет пропадать дефолтный
// ввод юзера. Поэтому чтобы это ввод не исчезал, приходится хранить его
// отдельно. Подробнее об этом будет позже, когда перейдём к месту
// реализации ввода сообщения.
pub user_input: Arc<Mutex<String>>,
// Массив, полученных с сервера сообщений.
pub messages: Arc<Mutex<Vec<String>>>
}
impl State {
pub fn new() -> io::Result<State> {
// Создание mpsc канала. Так как функция вернёт tuple, его можно
// сразу разбить на две переменные
let (sx, rx) = mpsc::channel::<()>();
let user_input = Arc::new(Mutex::new(String::new()));
let messages = Arc::new(Mutex::new(Vec::<String>::new()));
let mut instance = State {
username: String::new(),
chat_reload_receiver: Some(rx),
chat_reload_sender: sx,
user_input,
messages,
};
// Вызов метода для получения username'а
instance.read_username()?;
Ok(instance)
}
// Метод, который запрашивает у user'а ввод его ника и
// записывает полученные данные в state.
fn read_username(&mut self) -> io::Result<()> {
// Для некоторых манипуляций с терминалом, будем использовать termion.
// Библиотека позволяет "стирать" все из терминала, красить текст,
// менять режим у stdout'а (об этом позже) и тд.
// В данном случае нам нужно очистить терминал.
println!("{}", termion::clear::All);
print!("Username: ");
// Макрос print! добав ляет в буфер текст, но не выполняет flush
// и из-за этого после простого выполнения print! в консоли вы
// ничего не увидите. Чтобы это исправить нужно вызвать flush вручную.
std::io::stdout().flush()?;
let mut username = String::new();
// Чтение строки из stdin и запись содержимого в username
// через передачу мутабельной ссылки на username в read_line.
io::stdin().read_line(&mut username)?;
// Обрезаем с начала и конца ненужные символы
// (пробелы, перенос строки и тд) и записываем в наш объект State.
self.username = username.trim().to_owned();
// Снова всё очищаем.
println!("{}", termion::clear::All);
Ok(())
}
}
Код функции main для клиента (Файл client/src/main.rs)
use std::io;
use crate::{
settings::Settings,
state::State
};
mod settings;
mod state;
fn main() -> io::Result<()> {
let settings = Settings::new();
let state = State::new()?;
Ok(())
}