← На главную

Пишем простой REST-сервис на Rust

Некоторое время назад мы познакомились с асинхронным рантаймом Tokio. Пришло время написать с его помощью что-нибудь относительно полезное. Не будем оригинальными, и создадим очередную (первая, вторая) телефонную книгу с REST-интерфейсом.

Сразу перейдем к коду:

#[derive(Serialize, Deserialize, Clone)] struct PhonebookRecord { #[serde(default)] id: i64, name: String, phone: String, } #[derive(Serialize)] struct IdResponse { id: i64, } type Phonebook = Arc<RwLock<BTreeMap<i64, PhonebookRecord>>>; static NEXT_ID: AtomicI64 = AtomicI64::new(1); async fn create_record(state: State<Phonebook>, json: Json<PhonebookRecord>) ⏎ -> impl IntoResponse { let (State(phonebook), Json(mut req)) = (state, json); let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); req.id = id; { let mut phonebook = phonebook.write().await; phonebook.insert(id, req); } (StatusCode::CREATED, Json(IdResponse{ id })).into_response() } async fn read_record(state: State<Phonebook>, path: Path<i64>) ⏎ -> impl IntoResponse { let (State(phonebook), Path(id)) = (state, path); let rec: Option<PhonebookRecord> = { let phonebook = phonebook.read().await; phonebook.get(&id).map(|r| r.clone()) }; let code = if rec.is_some() { StatusCode::OK } else { StatusCode::NOT_FOUND }; (code, Json(rec)).into_response() } async fn update_record(state: State<Phonebook>, path: Path<i64>, ⏎ json: Json<PhonebookRecord>) -> impl IntoResponse { let (State(phonebook), Path(id), Json(req)) = (state, path, json); let found = { let mut phonebook = phonebook.write().await; if let Some(record) = phonebook.get_mut(&id) { record.name = req.name; record.phone = req.phone; true } else { false } }; let code = if found { StatusCode::OK } else { StatusCode::NOT_FOUND }; code.into_response() } async fn delete_record(state: State<Phonebook>, path: Path<i64>) ⏎ -> impl IntoResponse { let (State(phonebook), Path(id)) = (state, path); let found = { let mut phonebook = phonebook.write().await; phonebook.remove(&id).is_some() }; let code = if found { StatusCode::OK } else { StatusCode::NOT_FOUND }; code.into_response() } #[tokio::main] async fn main() { let phonebook: Phonebook = Arc::new(RwLock::new(BTreeMap::new())); let app = Router::new() .route("/api/v1/records", post(create_record)) .route("/api/v1/records/{id}", get(read_record)) .route("/api/v1/records/{id}", put(update_record)) .route("/api/v1/records/{id}", delete(delete_record)) .with_state(phonebook); let addr = "127.0.0.1:8080"; let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); println!("Listening on {}", addr); axum::serve(listener, app).await.unwrap(); }

Субъективно, код довольно простой, но кое-какие пояснения все же нужны.

Начнем с сериализации. Здесь применены популярные крейты serde и serde_json. Благодаря макросам мы просто добавляем #[derive(Serialize, Deserialize)] перед интересующей структурой, после чего можно использовать serde_json::to_string() для сериализации и serde_json::from_str() для десериализации. Макрос #[serde(default)] делает соответствующее поле структуры опциональным. Если его нет в JSON-документе, то при десериализации полю будет присвоено значение по умолчанию. Последнее определятся через стандартный трейт Default.

Выбор веб-фреймворка был сделан в пользу Axum. Это легковесный / модульный фреймворк, основанный на Tokio и библиотеке Hyper. Качественно Axum напоминает Scotty или Flask. То есть, фреймворк занимается непосредственно HTTP. Если вам нужен HTML-шаблонизатор или пул соединений к СУБД, то они реализуются в сторонних крейтах. Мне всегда импонировал такой подход.

Состояние телефонной книги хранится в памяти, в переменной с типом Arc<RwLock<BTreeMap<i64, PhonebookRecord>>>. Для получения следующего уникального id записи используется атомарная переменная NEXT_ID. Обращение к ней осуществляется с порядком доступа к памяти Ordering::Relaxed. Он гарантирует атомарность при работе с этой конкретной переменной, но не дает гарантий относительного порядка, в котором будут видны изменения других атомарных переменных (которых в данном коде и нет).

Сопоставление с образцом в стиле let Path(id) = path не обязательно писать в теле функции. С тем же успехом Rust позволяет использовать его прямо в аргументах. Однако я нахожу данный вариант менее читаемым. Также он плох тем, что в HTML-документации, генерируемой при помощи cargo doc, вместо говоряших имен аргументов будет что-то вроде __arg0.

Обратите внимание на затенение переменных вроде let phonebook = phonebook.read().await. Оно удобно, потому что не приходится придумывать переменным разные имена. Кроме того, благодаря затенению в рамках скоупа не выйдет обратиться не к той переменной. В книгах пишут, что для Rust такой код является идиоматичным.

Примеры работы с телефонной книгой:

# добавляем \n в конце вывода curl $ echo 'write-out = "\n"' >> ~/.curlrc # создание записи $ curl -XPOST -H 'Content-Type: application/json' \ -d '{"name":"Alice","phone":"123"}' 127.0.0.1:8080/api/v1/records {"id":1} # чтение записи $ curl 127.0.0.1:8080/api/v1/records/1 {"id":1,"name":"Alice","phone":"123"} # обновление записи $ curl -XPUT -H 'Content-Type: application/json' \ -d '{"name":"Bob","phone":"456"}' 127.0.0.1:8080/api/v1/records/1 # удаление записи $ curl -XDELETE 127.0.0.1:8080/api/v1/records/1

Для детального изучения кода с отображением выведенных типов, документации к ним и т.д. рекомендую использовать современные текстовые редакторы, такие как VSCode или Zed. Здесь я воздержусь от разбора всех типов и трейтов. Во-первых, для успешного использования Axum их знать не обязательно. Во-вторых, Axum имеет версию меньше 1.0, а значит со временем информация наверняка устареет. Полную версию исходников к посту вы найдете в этом архиве.