Полгода назад я писал заметку о выборе языка для нового проекта и там указал, что не рассматривал Rust. Первое знакомство с Rust у меня было несколько лет назад, когда я на нем пытался написать драйвер для фискального регистратора (ФР) и подход естественно тогда был как замена С++: прямая работа с указателями, битовая арифметика и прочее. С тех пор у меня было убеждение, что Rust – это для системного низкоуровневого программирования.
В общем выбрал я Go и первое время все меня устраивало в нем: быстрая разработка, хорошая читаемость (через пол года можно взглянуть на код и сразу понять про что он), хорошо документированные библиотеки. По сути пишешь логику приложения, а не “борешься” с языком/платформой. Но с ростом проекта стало не хватать возможности делать более абстрактный код, а основной проблемой, из-за которой я вернулся опять к выбору языка, стала обработка ошибок в Go.
Что бы было понято в чем проблема, вот пример обработчика для fiber:
func sync(c *fiber.Ctx) error {
userData := c.Locals("user")
if userData == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
user := userData.(auth.User)
changedDaysIn := make([]syncData, 0, 10)
if err := c.BodyParser(&changedDaysIn); err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusBadRequest)
}
for i := range changedDaysIn {
dayIn := &changedDaysIn[i]
if dayIn.UserID != user.UserID {
continue
}
//получаем день из базы
dayInDb, err := repo.GetEstimatyByDay(user.UserID, dayIn.Date)
if errors.Is(err, gorm.ErrRecordNotFound) {
err = repo.AddEstimatesOfDays(&dayIn.EstimatesOfDays)
} else if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
} else {
changedDay, err := repo.GetChangedDay(user.UserID, dayIn.Date)
if errors.Is(err, gorm.ErrRecordNotFound) {
err = repo.UpdateEstimatyOfDay(&dayInDb)
} else if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
} else if changedDay.ChangeTime.Before(dayIn.ChangeTime) {
err = repo.UpdateEstimatyOfDay(&dayInDb)
if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
}
err = repo.DeleteChangedDay(dayIn.UserID, dayIn.Date)
if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
}
}
}
}
changedDays, err := repo.GetEntitiesByUser[repo.ChangedDay](user.UserID)
if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
}
result := make([]syncData, 0, len(changedDays))
for i := range changedDays {
estimate, err := repo.GetEstimatyByDay(user.UserID, changedDays[i].Date)
if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
}
result = append(result, syncData{
EstimatesOfDays: estimate,
ChangeTime: time.Now(),
})
err = repo.DeleteChangedDay(user.UserID, changedDays[i].Date)
if err != nil {
slog.Error(err.Error())
return c.SendStatus(fiber.StatusInternalServerError)
}
}
return c.JSON(result)
}
В данной маленькой функции аж 9 обработок ошибок через if err!=nil и они сильно “мусорят” логику. Если бы убрали эти обработки ошибок то получили бы более читаемый код. И в этом коде еще нет проверок указателей на nil…
В последнее время мне на Хабре попадались статьи по Rust и я решил еще раз рассмотреть его, но с точки зрения прикладного программирования. И больше всего вдохновила меня статья Rust Vs Go: A Hands-On Comparison. В ней показано, что код на Rust зачастую проще и короче чем на Go.
Вот тот же алгоритм на Rust. Кода меньше, абстракций больше.
async fn sync(
claims: Claims,
State(repo): State<Arc<Repository>>,
Json(payload): Json<Vec<SyncData>>,
) -> Result<impl IntoResponse, PgError> {
//Приоритет сервера. Если нет дня на сервере, то создаем. Если есть на сервере
//то проверяем таблицу Sync. Если дата изменения на сервере меньше даты изменения на сервере
//то обновляем.
let estimates = Entity::<Estimate>::new(repo.clone());
let changed_days = Entity::<ChangedDay>::new(repo.clone());
for data in payload {
if data.estimate.user_id != user_id {
continue;
}
match changed_days.get_by_day(user_id, data.estimate.date).await {
Ok(changed_day) if changed_day.change_time < data.change_time => {
estimates.upsert(user_id, data.estimate).await?;
changed_days.delete(user_id, changed_day.id).await?;
}
Err(PgError::NotFound) => {
estimates.upsert(user_id, data.estimate).await?;
}
Err(err) => return Err(err),
_ => (),
}
}
let mut result: Vec<SyncData> = Vec::new();
let changed_days_data = changed_days.get_all(user_id).await?;
for day in changed_days_data {
let estimate = estimates.get_by_day(user_id, day.date).await?;
result.push(SyncData {
estimate,
heart_status: HeartStatus::default(),
change_time: day.change_time,
});
changed_days.delete(user_id, day.id).await?;
}
Ok(result)
}
Для понимая языка решил сразу просто переписать сервис Go на Rust. Вместе с изучением языка это заняло около месяца. (а сейчас бы это у меня заняло несколько дней). И по итогу могу сказать, что Rust мне понравился больше, чем Go. Он решил “проблему” с обработкой ошибок и дал инструменты, позволяющие писать более абстрактный код (generics и macros), правда ценой некоторого ухудшения понимания кода, когда к нему возвращаешься после длительного перерыва.
В целом мне кажется концептуально языки Go и Rust похожи. Для меня вообще Rust представляется как улучшения версия Go. Что же у них общего? Это:
- парадигма struct + trait / interface
- generics
- функции первого порядка / лямбды
- каналы для обмена между потоками
- простота подключения внешних зависимостей (пакетов)
- Компиляция в нативный код (в том числе со статической линковой)
И Rust к этому добавляет:
- Проброс ошибок с помощью оператора ‘?’, а так же типы Result и Options
- Переопределение ряда операторов, т.к. это трейты.
- Возможность реализовывать свои трейты (интерфейсы) для внешних типов (например для встроенных)
- enums содержащие значения
- мутабельные, иммутабельные и static переменные
- Контроль того, где будет размещаться значение: на стеке или в куче (Box<>)
- Более мощную систему generics
- lazy итераторы (в функциональном стиле)
- Выполнение кода в момент компиляции (макросы/атрибуты и static context)
- Контроль памяти на этапе компиляции, а не с использованием GC (это я отношу в плюсы Rust)
- Намного меньший размер скомпилированных файлов (что важно например для wasm)
- Нормальная работа с указателями (правда через unsafe)
- Нормальный FFI (в отличии от cgo в go)
Из-за большей гибкости Rust у него больше библиотек и они более функциональные. Например библиотека для сериализации/десериализации serde.
Основных преимуществ у Rust два:
- нет GC
- Нет утечек памяти, ссылок на не инициализированную память (естественно в safe). В Rust ссылки всегда ссылаются на существующее значение (в safe) и поэтому проверки на null не нужны. (данная проблема в Go описана в этой статье). Это автоматически закрывает кучу уязвимостей и предотвращает множество ошибок в рантайме. (а следовательно экономит очень много денег).
У Rust так же есть и минусы, в основном связанные с lifetime (пример). Но сложные проблемы с lifetime решаются через использование Rc/Arc.
Так же в Rust сложно сразу построить правильную архитектуру, нет устоявшихся практик. Программисты пытаются в Rust воссоздать подходы из ОПП, но это сложно.
Например:
В sqlx пул соединений/транзакция передается непосредственно в метод (.fetch_one(&conn)
). Писать одно и тоже при каждом вызове метода, да еще и тянуть зависимости через множество методов не хотелось, решил сделать структуру содержащую поле conn которое может быть или пулом или соединением и имплементировать для этой структуры методы CRUD. Так вот разработчик sqlx для трэйта Executor (который передается в методы работы с БД) указал ограничение Sized и из-за этого его нельзя использовать как трейт-объект. И поэтому нельзя создать такую структуру, пришлось писать отдельные методы для пула и отдельно для поддержки транзакций. Это пример того, как архитектура используемой библиотеки накладывает ограничение и кода приходится писать больше + теряется время на попытки реализации задуманного. В Go бы я без проблем передал интерфейс Executor и, если нужно, в рантайме, в зависимости от объекта интерфейса, сделал то что нужно.
Правда хочу отметить, что бизнес выбирает Go, т.к. на нем больше разработчиков и ниже порог входа. Но ИМХО это промежуточный этап. Те кто начал писать на Go в итоге перейдут на Rust.
Разработчики должны стремится к минимизации суммы следующих показателей (в относительных величинах, а не абсолютных):
- использование памяти
- использование процессора
- скорость написания ПО (скорость выкатывания новых фич)
- стоимость разработки (в части стоимости разработчиков)
Rust обходит Go в использовании памяти и скорости написания ПО (в Go больше писать и больше тестировать хотя бы из-за null pointer, ссылок не на те данные и т.п.). По использованию процессора Rust быстрее (см. раздел Бонус в этой статье) . А вот по стоимости разработки сложный вопрос. На написание идентичного функционала на Go нужно затратить больше часов. При этом у Go низкий порог входа и разработчики должны быть дешевые (т.к. Go способны выучить больше человек, чем Rust). Но судя по вакансиям Go разработчики наоборот одни из самых дорогих и получается, что разработка на Go дорогая, дороже чем на Rust. Бизнес же ориентируется только на количество разработчиков на конкретном языке, а на Go их пока в России больше чем на Rust и поэтому теряет деньги на неоптимальной технологии.