Роутер

Что такое роутинг

Router определяет, какие промежуточные обработчики (middleware) и Handler будут обрабатывать HTTP-запрос. Это базовая и ключевая функциональность в Salvo.

Внутри Router фактически состоит из набора фильтров (Filter). Когда поступает запрос, роутер последовательно проверяет соответствие себя и своих дочерних элементов запросу в порядке их добавления. Если соответствие найдено, последовательно выполняются все middleware во всей цепочке роутера и его дочерних элементов. Если в процессе обработки статус Response устанавливается как ошибка (4XX, 5XX) или перенаправление (3XX), последующие middleware и Handler пропускаются. Также можно вручную вызвать ctrl.skip_rest(), чтобы пропустить оставшиеся middleware и Handler.

В процессе сопоставления существует информация о пути URL, которую можно рассматривать как объект, который должен быть полностью "потреблен" фильтрами в процессе сопоставления. Если все фильтры в роутере успешно сопоставлены и информация о пути URL полностью "потреблена", считается, что сопоставление прошло успешно.

Например:

Router::with_path("articles").get(list_articles).post(create_article);

Фактически эквивалентно:

Router::new()
    // PathFilter может фильтровать путь запроса, успешное сопоставление произойдет только если путь содержит сегмент articles,
    // иначе сопоставление не удастся. Например: /articles/123 успешно сопоставится, а /articles_list/123 
    // не сопоставится, потому что после articles идет _list.
    .filter(PathFilter::new("articles"))

    // Если корневой роутер успешно сопоставлен и метод запроса - get, внутренний дочерний роутер успешно сопоставится,
    // и запрос будет обработан list_articles.
    .push(Router::new().filter(filters::get()).handle(list_articles))

    // Если корневой роутер успешно сопоставлен и метод запроса - post, внутренний дочерний роутер успешно сопоставится,
    // и запрос будет обработан create_article.
    .push(Router::new().filter(filters::post()).handle(create_article));

Если обратиться к GET /articles/, сопоставление будет успешным и выполнится list_articles. Но если обратиться к GET /articles/123, сопоставление не удастся и вернется ошибка 404, потому что Router::with_path("articles") "потребил" только /articles из информации о пути URL, оставив /123 необработанным, поэтому сопоставление считается неудачным. Чтобы сделать сопоставление успешным, роутер можно изменить так:

Router::with_path("articles/{**}").get(list_articles).post(create_article);

Здесь {**} сопоставится с любым оставшимся путем, поэтому он сможет обработать GET /articles/123 и выполнить list_articles.

Плоский стиль определения

Мы можем определять роуты в плоском стиле:

Router::with_path("writers").get(list_writers).post(create_writer);
Router::with_path("writers/{id}").get(show_writer).patch(edit_writer).delete(delete_writer);
Router::with_path("writers/{id}/articles").get(list_writer_articles);

Древовидный стиль определения

Также можно определять роуты в виде дерева, что является рекомендуемым способом:

Router::with_path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

Такой стиль определения делает структуру роутера более четкой и простой для сложных проектов.

Многие методы в Router возвращают Self, что позволяет писать код в цепочке. Иногда вам нужно решить, как маршрутизировать запросы, исходя из определенных условий. Система роутинга предоставляет функцию then, которую легко использовать:

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::with_path("{id}").get(show_article))
            .then(|router|{
                if admin_mode() {
                    router.post(create_article).push(
                        Router::with_path("{id}").patch(update_article).delete(delete_writer)
                    )
                } else {
                    router
                }
            }),
    );

Этот пример показывает, что только когда сервер находится в режиме admin_mode, будут добавлены роуты для создания, редактирования и удаления статей.

Получение параметров из роута

В приведенном выше коде {id} определяет параметр. Мы можем получить его значение через экземпляр Request:

#[handler]
async fn show_writer(req: &mut Request) {
    let id = req.param::<i64>("id").unwrap();
}

{id} сопоставляется с сегментом пути. Обычно id статьи - это просто число, поэтому мы можем использовать регулярное выражение, чтобы ограничить правило сопоставления для id, например r"{id|\d+}".

Для числовых типов есть более простой способ - использовать <id:num>:

  • {id:num} - сопоставляется с любым количеством цифр;
  • {id:num[10]} - сопоставляется ровно с 10 цифрами;
  • {id:num(..10)} - сопоставляется с 1 до 9 цифрами;
  • {id:num(3..10)} - сопоставляется с 3 до 9 цифрами;
  • {id:num(..=10)} - сопоставляется с 1 до 10 цифрами;
  • {id:num(3..=10)} - сопоставляется с 3 до 10 цифрами;
  • {id:num(10..)} - сопоставляется с 10 и более цифрами.

Также можно использовать {**}, {*+} или {*?} для сопоставления с оставшимися сегментами пути. Для лучшей читаемости кода можно добавить подходящее имя, чтобы сделать путь более понятным, например: {**file_path}.

  • {**}: означает, что сопоставленная часть может быть пустой строкой. Например, путь /files/{**rest_path} сопоставится с /files, /files/abc.txt, /files/dir/abc.txt;
  • {*+}: означает, что сопоставленная часть должна существовать и не может быть пустой строкой. Например, путь /files/{*+rest_path} не сопоставится с /files, но сопоставится с /files/abc.txt, /files/dir/abc.txt;
  • {*?}: означает, что сопоставленная часть может быть пустой строкой, но может содержать только один сегмент пути. Например, путь /files/{*?rest_path} не сопоставится с /files/dir/abc.txt, но сопоставится с /files, /files/abc.txt.

Можно комбинировать несколько выражений для сопоставления с одним сегментом пути, например /articles/article_{id:num}/, /images/{name}.{ext}.

Добавление middleware

Middleware можно добавить с помощью функции hoop в роутере:

Router::new()
    .hoop(check_authed)
    .path("writers")
    .get(list_writers)
    .post(create_writer)
    .push(
        Router::with_path("{id}")
            .get(show_writer)
            .patch(edit_writer)
            .delete(delete_writer)
            .push(Router::with_path("articles").get(list_writer_articles)),
    );

В этом примере корневой роутер использует check_authed для проверки, вошел ли текущий пользователь в систему. Все дочерние роутеры будут подвержены влиянию этого middleware.

Если пользователи просто просматривают информацию о писателях и их статьи, мы можем разрешить им делать это без входа в систему. Мы можем определить роуты следующим образом:

Router::new()
    .push(
        Router::new()
            .hoop(check_authed)
            .path("writers")
            .post(create_writer)
            .push(Router::with_path("{id}").patch(edit_writer).delete(delete_writer)),
    )
    .push(
        Router::with_path("writers").get(list_writers).push(
            Router::with_path("{id}")
                .get(show_writer)
                .push(Router::with_path("articles").get(list_writer_articles)),
        ),
    );

Хотя два роутера имеют одинаковое определение пути path("articles"), они все равно могут быть добавлены к одному родительскому роутеру.

Фильтры

Router внутренне использует фильтры для определения соответствия роута. Фильтры поддерживают базовые логические операции or и and. Роутер может содержать несколько фильтров, и когда все фильтры успешно сопоставлены, роутер считается соответствующим.

Структура пути веб-сайта имеет древовидную форму, но эта структура не обязательно совпадает с древовидной структурой организации роутеров. Один путь на сайте может соответствовать нескольким узлам роутера. Например, некоторый контент по пути articles/ требует входа в систему, а другой - нет. Мы можем организовать пути, требующие входа, в роутер с middleware проверки аутентификации, а пути, не требующие входа, - в другой роутер без такой проверки:

Router::new()
    .push(
        Router::with_path("articles")
            .get(list_articles)
            .push(Router::new().path("{id}").get(show_article)),
    )
    .push(
        Router::with_path("articles")
            .hoop(auth_check)
            .post(list_articles)
            .push(Router::new().path("{id}").patch(edit_article).delete(delete_article)),
    );

Роутер использует фильтры для фильтрации запросов и отправки их соответствующим middleware и Handler.

path и method - два наиболее часто используемых фильтра. path используется для сопоставления с информацией о пути; method используется для сопоставления с методом запроса, например: GET, POST, PATCH и т.д.

Мы можем использовать and, or для соединения фильтров роутера:

Router::with_filter(filters::path("hello").and(filters::get()));

Фильтры пути

Фильтры на основе пути запроса используются наиболее часто. В фильтрах пути можно определить параметры, например:

Router::with_path("articles/{id}").get(show_article);
Router::with_path("files/{**rest_path}").get(serve_file)

В Handler можно получить параметры через функцию get_param объекта Request:

#[handler]
pub async fn show_article(req: &mut Request) {
    let article_id = req.param::<i64>("id");
}

#[handler]
pub async fn serve_file(req: &mut Request) {
    let rest_path = req.param::<i64>("rest_path");
}

Фильтры метода

Фильтруют запросы на основе HTTP-метода, например:

Router::new().get(show_article).patch(update_article).delete(delete_article);

Здесь get, patch, delete - это фильтры методов. Фактически это эквивалентно:

use salvo::routing::filter;

let mut root_router = Router::new();
let show_router = Router::with_filter(filters::get()).handle(show_article);
let update_router = Router::with_filter(filters::patch()).handle(update_article);
let delete_router = Router::with_filter(filters::get()).handle(delete_article);
Router::new().push(show_router).push(update_router).push(delete_router);

Пользовательские Wisp

Для часто встречающихся выражений сопоставления мы можем зарегистрировать короткое имя с помощью PathFilter::register_wisp_regex или PathFilter::register_wisp_builder. Например, формат GUID часто встречается в путях, и обычно его нужно сопоставлять так:

Router::with_path("/articles/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");
Router::with_path("/users/<id:/[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}/>");

Каждый раз писать такое сложное регулярное выражение легко ошибиться, и код становится менее читаемым. Вместо этого можно сделать так:

use salvo::routing::filter::PathFilter;

#[tokio::main]
async fn main() {
    let guid = regex::Regex::new("[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}").unwrap();
    PathFilter::register_wisp_regex("guid", guid);
    Router::new()
        .push(Router::with_path("/articles/{id:guid}").get(show_article))
        .push(Router::with_path("/users/{id:guid}").get(show_user));
}

Достаточно зарегистрировать один раз, и в дальнейшем можно использовать простую запись {id:guid} для сопоставления с GUID, упрощая написание кода.

Как понять Router, если ранее изучались фреймворки класса Controller?

Основные отличия фреймворков с роутингом (таких как Salvo) от традиционных MVC или фреймворков с контроллерами:

  • Гибкость: Роутинг позволяет более гибко определять процесс обработки запросов, точно контролируя логику обработки каждого пути. Например, в Salvo вы можете напрямую определить функцию обработки для конкретного пути:

    Router::with_path("articles").get(list_articles).post(create_article);

    В то время как в подходе с контроллерами обычно нужно сначала определить класс контроллера, а затем методы в нем для обработки разных запросов:

    @Controller
    public class ArticleController {
        @GetMapping("/articles")
        public List<Article> listArticles() { /* ... */ }
        
        @PostMapping("/articles")
        public Article createArticle(@RequestBody Article article) { /* ... */ }
    }
  • Интеграция middleware: Фреймворки с роутингом обычно предоставляют более простые способы интеграции middleware, которые можно применять к конкретным роутам. В Salvo middleware можно точно применять к определенным роутам:

    Router::new()
        .push(
            Router::with_path("admin/articles")
                .hoop(admin_auth_middleware)  // middleware аутентификации только для роутов администратора
                .get(list_all_articles)
                .post(create_article),
        )
        .push(
            Router::with_path("articles")  // публичные роуты без аутентификации
                .get(list_public_articles),
        );
  • Организация кода: Подход с роутингом склоняется к организации кода по функциональности или конечным точкам API, а не по слоям модели-представления-контроллера. Роутинг поощряет организацию кода по функциональности конечных точек API:

    // user_routes.rs - роуты и логика обработки, связанные с пользователями
    pub fn user_routes() -> Router {
        Router::with_path("users")
            .get(list_users)
            .post(create_user)
            .push(Router::with_path("{id}").get(get_user).delete(delete_user))
    }
    
    // article_routes.rs - роуты и логика обработки, связанные со статьями
    pub fn article_routes() -> Router {
        Router::with_path("articles")
            .get(list_articles)
            .post(create_article)
    }
    
    // Комбинирование роутов в основном приложении
    let router = Router::new()
        .push(user_routes())
        .push(article_routes());
  • Легковесность: Обычно подход с роутингом более легковесный, уменьшая количество обязательных концепций и ограничений фреймворка. Вы можете включать только нужные компоненты, не следуя строгой структуре фреймворка.

Роутинг делает разработку API более интуитивной, особенно подходя для современных микросервисов и RESTful API. В таких фреймворках,