Эта статья — туториал по написанию небольшого чат сервиса (серверное и клиентское приложения) на 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(())
}
Прописывание общих типов для клиента и сервера
Теперь когда у нас готовы базовые вещи, можно начинать делать логику.
И так, у нас сервер и клиент будут передавать друг другу сообщения в одном и том же формате. Эти сообщения называются “сигналы”. Сам формат сигналов похож на формат передаваемых данных в http, только очень сильно упрощен.
В начале сигнала идут хедеры (список ниже). Хедеры разделяются символами “\r\n”.
/*
Кто отправляет Поле Значение
USER USERNAME Строка
SERVER AUTH_STATUS "ACCEPTED", "DECLINED"
USER+SERVER WITH_MESSAGE Нет
USER+SERVER SIGNAL_TYPE "CONNECTION", "NEW_MESSAGE"
SERVER SERVER_MESSAGE Нет
*/
/*
Определения хедеров
USERNAME Имя пользователя, от которого пришло сообщение
AUTH_STATUS Статус авторизации
WITH_MESSAGE В сигнале есть сообщение
SIGNAL_TYPE Тип сигнала: запрос на авторизацию или сообщение
SERVER_MESSAGE Серверное сообщение
*/
Потом в случае если в хедерах сигнала есть “WITH_MESSAGE”, то после хедеров идет ещё один разделитель “\r\n” и начинается сообщение, которое заканчивается символами “\r\n\r\n”.
Нам нужно уметь парсить сигналы и легко формировать свои. Для этого нужно прописать ряд типов, которые будут иметь вспомогательные методы, которыми мы будем пользоваться.
Перед этим создадим нашу кастомную ошибку, которую мы будем отдавать при возникновении проблем с парсингом.
Код кастомной ошибки парсинга (начало файла с типами <client & server>/src/types.rs)
use std::{
// Импорт утилит для форматирования и вывода строк
fmt,
// Импорт трейта Error (все ошибки как правило должны его имлементить)
error::Error
};
// Трейт Debug обязателен для Error, поэтому навешиваем
// макрос Debug на нашу структуру.
#[derive(Debug)]
pub struct ParseSignalDataError;
// impl Error для структуры. Внутри при желании можно не
// реализовывать методы, потому что все они реализованы по умолчанию.
impl Error for ParseSignalDataError {}
// Error так же требует реализации трейта fmt::Display
impl fmt::Display for ParseSignalDataError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "invalid signal data")
}
}
Можно начинать постепенно реализовывать нужные типы. Первым из них будет простенькие enum SignalType.
Он описывает 2 варианта возможных типов сигнала: Connection
(подключение, отправляется клиентом серверу), NewMessage
(но вое сообщение, клиент и сервер отправляют их друг другу).
Код enum’а SignalType (продолжение файла с типами <client & server>/src/types.rs)
// ...
#[derive(Debug, Clone, Copy)]
pub enum SignalType {
Connection,
NewMessage,
}
// Трейт FromStr идет из стандартной библиотеки и добавляет функцию
// для создания нужного типа из строки.
impl FromStr for SignalType {
type Err = ParseSignalDataError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"CONNECTION" => Ok(SignalType::Connection),
"NEW_MESSAGE" => Ok(SignalType::NewMessage),
_ => Err(ParseSignalDataError)
}
}
}
// Трейт ToString также идёт из стандартной библиотеки и добавляет функцию
// для создания уже строки из типа.
impl ToString for SignalType {
fn to_string(&self) -> String {
match self {
// .to_owned() делает из заимствованного типа (ссылки), владеющий.
// В данном случае делает из &str String. Подробно об этом
// останавливаться не буду. Лучше отдельно почитать статьи про
// ownership модель в Rust.
SignalType::Connection => "CONNECTION".to_owned(),
SignalType::NewMessage => "NEW_MESSAGE".to_owned(),
}
}
}
Далее идёт ещё один простой enum AuthStatus
. Он содержит значения, которые возвращает сервер в ответ на попытку авторизации юзером. ACCEPTED
— авторизация прошла успешно, DENIED
— авторизация отклонена. После DENIED
соединение сбрасывается.
Код enum’а AuthStatus (продолжение файла с типами <client & server>/src/types.rs)
// ...
#[derive(Debug, Clone, Copy)]
pub enum AuthStatus {
ACCEPTED,
DENIED
}
impl FromStr for AuthStatus {
type Err = ParseSignalDataError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"ACCEPTED" => Ok(AuthStatus::ACCEPTED),
"DENIED" => Ok(AuthStatus::DENIED),
_ => Err(ParseSignalDataError)
}
}
}
impl ToString for AuthStatus {
fn to_string(&self) -> String {
match self {
AuthStatus::ACCEPTED => "ACCEPTED".to_owned(),
AuthStatus::DENIED => "DENIED".to_owned()
}
}
}
Теперь нужно реализовать enum SignalHeader
. Это уже тип поинтереснее, он содержит хедеры сигнала и значения, которые они передают. Подробнее про то, какие есть хедеры и какие значения имеют я писал выше, поэтому на этом не буду заострять особо внимание.
Код enum’а SignalHeader (продолжение файла с т ипами <client & server>/src/types.rs)
// ...
pub enum SignalHeader {
Username(String),
AuthStatus(AuthStatus),
SignalType(SignalType),
WithMessage,
ServerMessage
}
impl FromStr for SignalHeader {
type Err = ParseSignalDataError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let (header, value) = s.split_once(':').unwrap_or((s, s));
match header {
"USERNAME" => Ok(SignalHeader::Username(value.trim().to_owned())),
"AUTH_STATUS" => {
match AuthStatus::from_str(value.trim()) {
Ok(v) => return Ok(SignalHeader::AuthStatus(v)),
Err(_) => Err(ParseSignalDataError)
}
},
"SIGNAL_TYPE" => {
match SignalType::from_str(value.trim()) {
Ok(v) => return Ok(SignalHeader::SignalType(v)),
Err(_) => Err(ParseSignalDataError)
}
}
"WITH_MESSAGE" => Ok(SignalHeader::WithMessage),
"SERVER_MESSAGE" => Ok(SignalHeader::ServerMessage),
_ => Err(ParseSignalDataError)
}
}
}
impl ToString for SignalHeader {
fn to_string(&self) -> String {
match self {
SignalHeader::Username(v) => format!("USERNAME: {v}\r\n"),
SignalHeader::AuthStatus(v) => format!("AUTH_STATUS: {}\r\n", v.to_string()),
SignalHeader::SignalType(v) => format!("SIGNAL_TYPE: {}\r\n", v.to_string()),
SignalHeader::WithMessage => "WITH_MESSAGE\r\n".to_owned(),
SignalHeader::ServerMessage => "SERVER_MESSAGE\r\n".to_owned()
}
}
}