Приемы обобщенного программирования в Rust: как мы переводили Exonum с Iron на actix-web

    Экосистема Rust еще не до конца устоялась. В ней часто появляются новые библиотеки, которые заметно лучше предшественников, а ранее популярные фреймворки устаревают. Именно это произошло с веб-фреймворком Iron, который мы использовали при разработке Exonum.

    В качестве замены Iron был выбран actix-web. Дальше я расскажу, как мы портировали существующий код на новое решение, используя приемы обобщённого программирования.


    Изображение ulleo PD

    Как мы использовали Iron


    В Exonum фреймворк Iron использовался без каких-либо абстракций. Мы устанавливали обработчики на определенные ресурсы, параметры запросов получали путем разбора URL вспомогательными методами, а результат возвращали просто в виде строки.

    Выглядело это все примерно так:

    fn set_blocks_response(self, router: &mut Router) {
        let blocks = move |req: &mut Request| -> IronResult<Response> {
            let count: usize = self.required_param(req, "count")?;
            let latest: Option<u64> = self.optional_param(req, "latest")?;
            let skip_empty_blocks: bool = self.optional_param(req, "skip_empty_blocks")?
                .unwrap_or(false);
            let info = self.blocks(count, latest.map(Height), skip_empty_blocks)?;
            self.ok_response(&::serde_json::to_value(info).unwrap())
        };
    
        router.get("/v1/blocks", blocks, "blocks");
    }
    

    Помимо этого, использовались некоторые middleware-дополнения в виде CORS-заголовков. Для объединения всех обработчиков в единый API мы использовали mount.

    Почему пришлось от него отказаться


    Iron был неплохой «рабочей лошадкой» с большим количеством дополнений. Однако писался он в те далекие времена, когда таких проектов как futures и tokio не существовало.

    Архитектура Iron предусматривает синхронную обработку запросов, поэтому он легко укладывался на лопатки большим количеством одновременно открытых подключений. Чтобы Iron стал масштабируемым, его следовало сделать асинхронным. Для этого было необходимо переосмыслить и переписать весь фреймворк, но разработчики постепенно забросили работу над ним.

    Почему мы перешли на actix-web


    Это популярный фреймворк, который занимает высокие места в бенчмарках от TechEmpower. При этом он, в отличие от Iron, активно развивается. Actix-web имеет грамотно спроектированный API и качественную реализацию на основе акторного фреймворка actix. Запросы обрабатываются асинхронно пулом потоков, а если обработка приводит к панике, то актор автоматически перезапускается.

    Разумеется, у actix-web были недостатки, например, он содержал большое количество unsafe-кода. Но позже он был переписан на безопасном Rust, что решило эту проблему.

    Переход на actix решил проблему со стабильностью работы. Iron-бэкенд можно было уронить большим количеством подключений. В целом, новый API представляет собой более простое, производительное и унифицированное решение. Пользователям и разработчикам станет проще пользоваться программным интерфейсом, а скорость его работы увеличится.

    Чего мы хотим от веб-фреймворка


    Нам важно было не просто сменить Iron на actix-web, а сделать задел на будущее — проработать новую архитектуру API для абстрагирования от конкретного веб-фреймворка. Это позволит создавать обработчики, почти не задумываясь о веб-специфике и переносить их на любой бекенд. Сделать это можно путем написания фронтенда, который бы оперировал базовыми типами и типажами.

    Чтобы понять, как этот фронтенд выглядит, определим, чем является любой HTTP API:

    • Запросы делают исключительно клиенты, а сервер лишь отвечает на них (он не выступает инициатором).
    • Запросы бывают на чтение и на изменение.
    • В результате выполнения запроса сервер возвращает ответ, в котором при успехе содержатся искомые данные, а в случае ошибки — информация о ней.

    Если проанализировать все слои абстракции, то получится, что любой HTTP-запрос — это просто вызов функции:

    fn request(context: &ServiceContext, query: Query) -> Result<Response, ServiceError>

    Все остальное можно считать расширениями этой базовой сущности. Таким образом, чтобы абстрагироваться от конкретной реализации веб-фреймворка, нам нужно писать обработчики в похожем на пример выше стиле.

    Типаж Endpoint для обобщённой обработки HTTP-запросов

    Можно пойти самым простым и прямолинейным путем и объявить типаж Endpoint,
    описывающий реализации конкретных запросов:

    // Типаж, описывающий обработчики GET запросов. Каждый обработчик должен уметь вызываться
    // из любого освободившегося потока, что накладывает некоторые ограничения на типаж.
    // Параметры и результат запроса конфигурируются с помощью ассоциированных типов.
    trait Endpoint: Sync + Send + 'static {
        type Request: DeserializeOwned + 'static;
        type Response: Serialize + 'static;
    
        fn handle(&self, context: &Context, request: Self::Request) -> Result<Self::Response, io::Error>;
    }
    

    После чего потребуется реализовать этот обработчик в конкретном фреймворке. Скажем, для actix-web это выглядит примерно так:

    // Тип ответов в actix-web. Обратите внимание, что они асинхронные,
    // хотя `Endpoint` предполагает синхронную обработку.
    type FutureResponse = actix_web::FutureResponse<HttpResponse, actix_web::Error>;
    
    // «Сырой» обработчик запросов для actix-web. В конечном счете фреймворк ожидает
    // получить именно его. Этот обработчик параметризуется произвольным контекстом,
    // через который передаются аргументы запроса.
    type RawHandler = dyn Fn(HttpRequest<Context>) -> FutureResponse + 'static + Send + Sync;
    
    // Давайте соберем все, что нам нужно от обработчика, в единую структуру для удобства.
    #[derive(Clone)]
    struct RequestHandler {
        /// Имя ресурса.
        pub name: String,
        /// HTTP метод.
        pub method: actix_web::http::Method,
        /// Сырой обработчик. Обратите внимание, что он будет использоваться из нескольких потоков.
        pub inner: Arc<RawHandler>,
    }
    

    Для передачи параметров запроса через контекст можно использовать структуры. Actix-web умеет проводить автоматическую десериализацию параметров с помощью serde. К примеру, a=15&b=hello десериализуется в структуру следующего вида:

    #[derive(Deserialize)]
    struct SimpleQuery {
        a: i32,
        b: String,
    }
    

    Это хорошо согласуется с ассоциированным типом Request из типажа Endpoint.

    Теперь напишем адаптер, «оборачивающий» конкретную реализацию Endpoint в RequstHandler для actix-web. Обратите внимание, что в процессе теряется информация о типах Request и Response. Такая техника называется type erasure. Её задача — превращать статическую диспетчеризацию в динамическую.

    impl RequestHandler {
        fn from_endpoint<E: Endpoint>(name: &str, endpoint: E) -> RequestHandler {
            let index = move |request: HttpRequest<Context>| -> FutureResponse {
                let context = request.state();
                let future = Query::from_request(&request, &())
                    .map(|query: Query<E::Request>| query.into_inner())
                    .and_then(|query| endpoint.handle(context, query).map_err(From::from))
                    .and_then(|value| Ok(HttpResponse::Ok().json(value)))
                    .into_future();
                Box::new(future)
            };
    
            Self {
                name: name.to_owned(),
                method: actix_web::http::Method::GET,
                inner: Arc::from(index) as Arc<RawHandler>,
            }
        }
    }
    

    На этом этапе можно дописать обработчики для POST-запросов и остановиться, так как мы создали типаж, абстрагированный от деталей реализации. Однако он все еще не слишком эргономичен.

    Проблемы типажа

    При написании обработчика образуется много вспомогательного кода:

    // Структура с контекстом обработчика.
    struct ElementCountEndpoint {
        elements: Rc<RefCell<Vec<Something>>>,
    }
    
    // Реализация типажа Endpoint.
    impl Endpoint for ElementCountEndpoint {
        type Request = ();
        type Result = usize;
    
        fn handle(&self, context: &Context, _request: ()) -> Result<usize, io::Error> {
            Ok(self.elements.borrow().len())
        }
    }
    
    // Установка обработчика в бэкенд.
    let endpoint = ElementCountEndpoint::new(elements.clone());
    let handler = RequestHandler::from_endpoint("/v1/element_count", endpoint);
    actix_backend.endpoint(handler);
    

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

    let elements = elements.clone();
    actix_backend.endpoint("/v1/elements_count", move || {
        Ok(elements.borrow().len())
    });
    

    О том, как это сделать, расскажу далее.

    Легкое погружение в обобщенное программирование


    Нам нужно реализовать возможность автоматически генерировать адаптер, реализующий Endpoint с правильными ассоциированными типами. На вход при этом будет подаваться лишь замыкание с обработчиком HTTP-запроса.

    Аргументы и результат замыкания могут иметь различные типы, поэтому здесь придется работать с перегрузкой методов. Rust не поддерживает перегрузку напрямую, но позволяет её эмулировать с помощью типажей Into и From.

    Кроме того, тип возвращаемого значения замыкания не обязан совпадать с возвращаемым значением реализации Endpoint. Чтобы манипулировать этим типом, его нужно извлечь из типа принимаемого замыкания.

    Извлечение типов из типажа Fn

    В Rust каждое замыкание имеет свой уникальный тип, который нельзя явно записать в программе. Для манипуляции замыканиями существует типаж Fn. Он содержит сигнатуру функции с типами аргументов и возвращаемого значения, однако извлечь их по-отдельности не так просто.

    Основная идея заключается в использовании вспомогательной структуры следующего вида:

    /// Упрощенный пример извлечения типов из замыкания F: Fn(A) -> B.
    struct SimpleExtractor<A, B, F>
    {
        // Сама исходная функция.
        inner: F,
        _a: PhantomData<A>,
        _b: PhantomData<B>,
    }
    

    Мы вынуждены использовать PhantomData, так как Rust требует, чтобы все параметры обобщения были в определении структуры. Однако конкретный тип замыкания или функции F не является обобщённым (хоть и реализует обобщённый типаж Fn). Параметры-типы A и B в нём напрямую не используются.

    Именно это ограничение системы типов Rust и не позволяет использовать более простую стратегию — реализовать типаж Endpoint для замыканий напрямую:

    impl<A, B, F> Endpoint for F where F: Fn(&Context, A) -> B {
        type Request = A;
        type Response = B;
    
        fn handle(&self, context: &Context, request: A) -> Result<B, io::Error> {
            // ...
        }
    }
    

    Компилятор в таком случае возвращает ошибку:

    error[E0207]: the type parameter `A` is not constrained by the impl trait, self type, or predicates
      --> src/main.rs:10:6
       |
    10 | impl<A, B, F> Endpoint for F where F: Fn(&Context, A) -> B {
       |      ^ unconstrained type parameter
    

    Вспомогательная структура SimpleExtractor даёт возможность описать преобразование From. Оно позволяет сохранить любую функцию и извлечь типы её аргументов:

    impl<A, B, F> From<F> for SimpleExtractor<A, B, F>
    where
        F: Fn(&Context, A) -> B,
        A: DeserializeOwned,
        B: Serialize,
    {
        fn from(inner: F) -> Self {
            SimpleExtractor {
                inner,
                _a: PhantomData,
                _b: PhantomData,
            }
        }
    }
    

    Следующий код успешно компилируется:

    #[derive(Deserialize)]
    struct Query {
        a: i32,
        b: String,
    };
    
    // Проверяем обычную функцию.
    fn my_handler(_: &Context, q: Query) -> String {
        format!("{} has {} apples.", q.b, q.a)
    }
    let fn_extractor = SimpleExtractor::from(my_handler);
    
    // Проверяем замыкание.
    let c = 15;
    let my_closure = |_: &Context, q: Query| -> String {
        format!("{} has {} apples, but Alice has {}", q.b, q.a, c)
    };
    let closure_extractor = SimpleExtractor::from(my_closure);
    

    Специализация и маркерные типы

    Теперь у нас имеется функция с явно параметризированными типами аргументов, пригодная для использования вместо типажа Endpoint. Например, мы легко можем реализовать преобразование из SimpleExtractor в RequestHandler. Но все-таки это не полное решение. Еще нужно как-то отличать обработчики GET-запросов от POST-запросов на уровне типов (и синхронные обработчики от асинхронных). В этом нам помогут так называемые типы-маркеры.

    Для начала перепишем SimpleExtractor так, чтобы он мог различать синхронный результат от асинхронного. Заодно реализуем типаж From для каждого из случаев. Обратите внимание, что типажи можно реализовывать для конкретных вариантов обобщенных структур.

    /// Обобщённый обработчик HTTP-запросов.
    pub struct With<Q, I, R, F> {
        /// Конкретная функция-обработчик.
        pub handler: F,
        /// Тип структуры с параметрами запроса.
        _query_type: PhantomData<Q>,
        /// Тип результата запроса.
        _item_type: PhantomData<I>,
        /// Тип значения, возвращаемого обработчиком.
        /// Обратите внимание, что он может отличаться от результата запроса.
        _result_type: PhantomData<R>,
    }
    
    // Реализация для обычного, синхронно возвращаемого значения.
    impl<Q, I, F> From<F> for With<Q, I, Result<I>, F>
    where
        F: Fn(&ServiceApiState, Q) -> Result<I>,
    {
        fn from(handler: F) -> Self {
            Self {
                handler,
                _query_type: PhantomData,
                _item_type: PhantomData,
                _result_type: PhantomData,
            }
        }
    }
    
    // Реализация для асинхронного обработчика запросов.
    impl<Q, I, F> From<F> for With<Q, I, FutureResult<I>, F>
    where
        F: Fn(&ServiceApiState, Q) -> FutureResult<I>,
    {
        fn from(handler: F) -> Self {
            Self {
                handler,
                _query_type: PhantomData,
                _item_type: PhantomData,
                _result_type: PhantomData,
            }
        }
    }
    

    Теперь нужно объявить структуру, в которой объединить обработчик запроса с его именем и разновидностью:

    #[derive(Debug)]
    pub struct NamedWith<Q, I, R, F, K> {
        /// Имя обработчика.
        pub name: String,
        /// Обработчик с извлеченными типами.
        pub inner: With<Q, I, R, F>,
        /// Разновидность обработчика.
        _kind: PhantomData<K>,
    }
    

    После можно объявить несколько пустых структур, которые будут выступать в роли типов-маркеров. Маркеры позволяют реализовать для каждого из обработчиков свой код преобразования к описанному ранее RequestHandler.

    /// Обработчик, не изменяющий состояние сервиса. В HTTP ему соответствуют GET-запросы.
    pub struct Immutable;
    /// Обработчик, изменяющий состояние сервиса. В HTTP ему соответствуют POST, PUT, UPDATE
    /// и тому подобные запросы, но для наших нужд достаточно лишь одних POST.
    pub struct Mutable;
    

    Теперь мы можем определить четыре различных реализации типажа From для всех комбинаций шаблонных параметров R и K (возвращаемое значение обработчика и разновидность запроса).

    // Реализация для синхронного обработчика get запросов.
    impl<Q, I, F> From<NamedWith<Q, I, Result<I>, F, Immutable>> for RequestHandler
    where
        F: Fn(&ServiceApiState, Q) -> Result<I> + 'static + Send + Sync + Clone,
        Q: DeserializeOwned + 'static,
        I: Serialize + 'static,
    {
        fn from(f: NamedWith<Q, I, Result<I>, F, Immutable>) -> Self {
            let handler = f.inner.handler;
            let index = move |request: HttpRequest| -> FutureResponse {
                let context = request.state();
                let future = Query::from_request(&request, &())
                    .map(|query: Query<Q>| query.into_inner())
                    .and_then(|query| handler(context, query).map_err(From::from))
                    .and_then(|value| Ok(HttpResponse::Ok().json(value)))
                    .into_future();
                Box::new(future)
            };
    
            Self {
                name: f.name,
                method: actix_web::http::Method::GET,
                inner: Arc::from(index) as Arc<RawHandler>,
            }
        }
    }
    // Реализация для синхронного обработчика post запросов.
    impl<Q, I, F> From<NamedWith<Q, I, Result<I>, F, Mutable>> for RequestHandler
    where
        F: Fn(&ServiceApiState, Q) -> Result<I> + 'static + Send + Sync + Clone,
        Q: DeserializeOwned + 'static,
        I: Serialize + 'static,
    {
        fn from(f: NamedWith<Q, I, Result<I>, F, Mutable>) -> Self {
            let handler = f.inner.handler;
            let index = move |request: HttpRequest| -> FutureResponse {
                let handler = handler.clone();
                let context = request.state().clone();
                request
                    .json()
                    .from_err()
                    .and_then(move |query: Q| {
                        handler(&context, query)
                            .map(|value| HttpResponse::Ok().json(value))
                            .map_err(From::from)
                    })
                    .responder()
            };
    
            Self {
                name: f.name,
                method: actix_web::http::Method::POST,
                inner: Arc::from(index) as Arc<RawHandler>,
            }
        }
    }
    // Реализация для асинхронного обработчика get запросов.
    impl<Q, I, F> From<NamedWith<Q, I, FutureResult<I>, F, Immutable>> for RequestHandler
    where
        F: Fn(&ServiceApiState, Q) -> FutureResult<I> + 'static + Clone + Send + Sync,
        Q: DeserializeOwned + 'static,
        I: Serialize + 'static,
    {
        fn from(f: NamedWith<Q, I, FutureResult<I>, F, Immutable>) -> Self {
            let handler = f.inner.handler;
            let index = move |request: HttpRequest| -> FutureResponse {
                let context = request.state().clone();
                let handler = handler.clone();
                Query::from_request(&request, &())
                    .map(move |query: Query<Q>| query.into_inner())
                    .into_future()
                    .and_then(move |query| handler(&context, query).map_err(From::from))
                    .map(|value| HttpResponse::Ok().json(value))
                    .responder()
            };
    
            Self {
                name: f.name,
                method: actix_web::http::Method::GET,
                inner: Arc::from(index) as Arc<RawHandler>,
            }
        }
    }
    // Реализация для асинхронного обработчика post запросов.
    impl<Q, I, F> From<NamedWith<Q, I, FutureResult<I>, F, Mutable>> for RequestHandler
    where
        F: Fn(&ServiceApiState, Q) -> FutureResult<I> + 'static + Clone + Send + Sync,
        Q: DeserializeOwned + 'static,
        I: Serialize + 'static,
    {
        fn from(f: NamedWith<Q, I, FutureResult<I>, F, Mutable>) -> Self {
            let handler = f.inner.handler;
            let index = move |request: HttpRequest| -> FutureResponse {
                let handler = handler.clone();
                let context = request.state().clone();
                request
                    .json()
                    .from_err()
                    .and_then(move |query: Q| {
                        handler(&context, query)
                            .map(|value| HttpResponse::Ok().json(value))
                            .map_err(From::from)
                    })
                    .responder()
            };
    
            Self {
                name: f.name,
                method: actix_web::http::Method::POST,
                inner: Arc::from(index) as Arc<RawHandler>,
            }
        }
    }
    

    «Фасад» для бэкенда

    Теперь для всего этого осталось написать «фасад», который бы принимал замыкания и добавлял их в соответствующий бэкенд. В нашем случае бэкенд лишь один — actix-web — но за фасадом можно спрятать какие угодно дополнительные реализации, например генератор Swagger-спецификаций.

    pub struct ServiceApiScope {
        actix_backend: actix::ApiBuilder,
    }
    
    impl ServiceApiScope {
        /// Этот метод добавляет Immutable обработчик во все бэкенды.
        pub fn endpoint<Q, I, R, F, E>(&mut self, name: &'static str, endpoint: E) -> &mut Self
        where
            // Здесь перечисляются вполне типичные ограничения, с которыми мы уже сталкивались ранее:
            Q: DeserializeOwned + 'static,
            I: Serialize + 'static,
            F: Fn(&ServiceApiState, Q) -> R + 'static + Clone,
            E: Into<With<Q, I, R, F>>,
            // Обратите внимание, что в список ограничений попало реализованное нами ранее преобразование
            // из NamedWith в RequestHandler.
            RequestHandler: From<NamedWith<Q, I, R, F, Immutable>>,
        {
            self.actix_backend.endpoint(name, endpoint);
            self
        }
    
        /// Аналогичный метод для Mutable обработчиков.
        pub fn endpoint_mut<Q, I, R, F, E>(&mut self, name: &'static str, endpoint: E) -> &mut Self
        where
            Q: DeserializeOwned + 'static,
            I: Serialize + 'static,
            F: Fn(&ServiceApiState, Q) -> R + 'static + Clone,
            E: Into<With<Q, I, R, F>>,
            RequestHandler: From<NamedWith<Q, I, R, F, Mutable>>,
        {
            self.actix_backend.endpoint_mut(name, endpoint);
            self
        }
    

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

    Недостатки подхода


    Такой подход все же обладает своими недостатками. В частности, endpoint и endpoint_mut должны знать особенности реализации конкретных бэкендов. Это не позволяет нам добавлять бэкенды на лету, но такая функциональность редко нужна.

    Еще одна проблема — нельзя определить специализацию для обработчика без дополнительных аргументов. Иными словами, если мы напишем следующий код, то он не скомпилируется, потому что конфликтует с существующей обобщенной реализацией:

    impl<(), I, F> From<F> for With<(), I, Result<I>, F>
       where
           F: Fn(&ServiceApiState) -> Result<I>,
       {
           fn from(handler: F) -> Self {
               Self {
                   handler,
                   _query_type: PhantomData,
                   _item_type: PhantomData,
                   _result_type: PhantomData,
               }
           }
       }

    В результате запросы, не имеющие параметров, все равно должны принимать JSON-строку null, которая десериализуется в (). Эту проблему могла бы решить специализация в стиле C++, но пока она доступна лишь в nightly-версии компилятора и непонятно, когда «стабилизируется».

    Аналогичным образом нельзя специализировать тип возвращаемого значения. Даже если запрос его не подразумевает, он всегда будет отдавать JSON с null.

    Декодирование URL query в GET-запросах тоже накладывает некоторые неочевидные ограничения на вид параметров, но это уже особенности реализации serde-urlencoded.

    Заключение


    Таким образом, мы реализовали API, который позволяет просто и понятно создавать обработчики, почти не задумываясь о веб-специфике. В последствии их можно переносить на любые бэкенды или даже использовать несколько бэкендов одновременно.
    • +38
    • 4,6k
    • 2

    Bitfury Group

    70,00

    Cофтверные и хардверные решения на Blockchain

    Поделиться публикацией
    Комментарии 2
      0

      Не думаю что можно обобщить регистрацию синхронного и асинхронного кода без специализации. В вашем случае R в типаже NamedWith, то есть можно но нужно всегда конкретный тип указывать а с комбинаторами это не возможно пока не завезут impl Trait в типажах. либо пока не стабилизируют specialization.

        +2

        Ну пока-что можно в лямбду завернуть код с комбинаторами, не очень красиво конечно, но если очень надо, то сработает.
        Впрочем в случае с объявлением API мне кажется комбинаторы в целом не очень эргономично выглядят.
        Вы имеете в виду impl trait в ассоциированных типах?

      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

      Самое читаемое