← Оглавление

7.1 Состояние двух потоков

В цеху с несколькими мастерами один журнал - на общей полке. Не у первого мастера на верстаке, не у второго: на полке, куда есть доступ у всех. Иначе каждый ведёт свой учёт, и к вечеру никто не знает картины целиком. Когда нет общей полки, у каждого своя версия. А своя версия у каждого - значит, ни у кого нет правды.

Что нужно

Чтобы подключить хранилище к серверу, нужно решить три задачи: как передать его обработчикам, как поделить между несколькими потоками, и как безопасно менять из любого из них. Каждая - отдельная глава. Здесь - первая.

Обработчики - это функции, которые axum вызывает по одной для каждого входящего запроса. add, list, get_task не знают о main() и не имеют доступа к переменным внутри него. Нужно хранилище, которое живёт дольше одного запроса и доступно всем обработчикам сразу. Прежде чем подключать TaskStore, разберёмся с механизмом: пока в роли хранилища выступит Vec<Task>.

У axum для этого есть State: .with_state() прикрепляет объект к роутеру, а экстрактор State<S> - тот же принцип, что у Path и Json - достаёт его в обработчике. State<S> требует, чтобы S реализовывал Clone: axum клонирует состояние при каждом вызове обработчика.

Постройка

Vec<T> реализует Clone только когда T: Clone - значит, нужен Clone на Task, и на Status - поле Task тоже должно реализовывать Clone. В crates/core/src/task/mod.rs:

// CHANGED: Clone нужен, чтобы Vec<Task> мог реализовать Clone
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Status { Todo, Done }

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Task { ... }

В crates/api/src/routes.rs заменяем заглушки на State<Vec<Task>>. В add и list добавим eprintln! - он напечатает адрес вектора в памяти и поможет понять, что происходит с данными при каждом запросе:

// crates/api/src/routes.rs - CHANGED
use axum::{
    Json, Router,
    extract::{Path, State},
    routing::{get, patch, post},
};
use serde::Deserialize;
use tq_core::task::Task;

#[derive(Deserialize)]
struct AddRequest {
    title: String,
}

// State(mut tasks) - axum передаёт владение клоном; mut нужен, чтобы изменить вектор
async fn add(State(mut tasks): State<Vec<Task>>, Json(req): Json<AddRequest>) -> Json<Task> {
    eprintln!("add:  vec @ {:p}", tasks.as_ptr()); // {:p} - адрес вектора в памяти
    let id = (tasks.len() + 1) as u64;
    let task = Task::new(id, &req.title).unwrap();
    tasks.push(task.clone()); // clone: задача нужна и в векторе, и в ответе
    Json(task)
}

async fn list(State(tasks): State<Vec<Task>>) -> Json<Vec<Task>> {
    eprintln!("list: vec @ {:p}", tasks.as_ptr());
    Json(tasks)
}

async fn get_task(State(tasks): State<Vec<Task>>, Path(id): Path<u64>) -> Json<Task> {
    Json(tasks.into_iter().find(|t| t.id == id).unwrap())
}

async fn done(State(mut tasks): State<Vec<Task>>, Path(id): Path<u64>) -> Json<Task> {
    let task = tasks.iter_mut().find(|t| t.id == id).unwrap();
    task.complete();
    Json(task.clone())
}

pub fn router(tasks: Vec<Task>) -> Router {
    Router::new()
        .route("/tasks", post(add).get(list))
        .route("/tasks/{id}", get(get_task))
        .route("/tasks/{id}/done", patch(done))
        .with_state(tasks) // прикрепляет состояние к роутеру
}

В crates/api/src/main.rs собираем начальное состояние и передаём его роутеру через .with_state():

// crates/api/src/main.rs - CHANGED
mod routes;

use axum::{Router, routing::get};
use tq_core::task::Task;

async fn health() -> String {
    "tq ok".to_string()
}

#[tokio::main]
async fn main() {
    let tasks: Vec<Task> = vec![ // начальное состояние: axum клонирует его в каждый запрос
        Task::new(1, "buy milk").unwrap(),
        Task::new(2, "send report").unwrap(),
    ];
    let app = Router::new()
        .route("/", get(health))
        .merge(routes::router(tasks));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Результат

В одном терминале - сервер (make serve), в другом - три запроса подряд:

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk",...},{"id":2,"title":"send report",...}]

$ curl -s -X POST localhost:3000/tasks \
    -H "Content-Type: application/json" -d '{"title":"call dentist"}'
{"id":3,"title":"call dentist",...}

$ curl -s localhost:3000/tasks
[{"id":1,"title":"buy milk",...},{"id":2,"title":"send report",...}]

call dentist исчезла. Сервер принял запрос, вернул задачу с id 3 - но следующий list её не видит. В терминале сервера видно почему:

list: vec @ 0x55f3a1b2c4d0
add:  vec @ 0x55f3a1b2c5e8   ← другой адрес
list: vec @ 0x55f3a1b2c6a0   ← снова другой

Каждый запрос получил свой клон вектора. add добавил задачу в свой клон - и унёс её с собой, когда запрос завершился. Следующий list знать ничего не знал о call dentist.

Clone выполнил задание компилятора. У каждого обработчика теперь есть журнал - просто у каждого свой. Журнал на общей полке - следующий шаг.

Полный код tq для этой главы - 7-many-hands/01-the-state-two-threads-share/.