Пишем простой 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, а значит со временем информация наверняка устареет. Полную версию исходников к посту вы найдете в этом архиве.