Verallgemeinerte Programmiertechniken in Rust: Wie wir Exonum von Iron nach Actix-Web übersetzt haben

Das Rust-Ökosystem ist noch nicht vollständig besiedelt. Darin erscheinen häufig neue Bibliotheken, die deutlich besser sind als ihre Vorgänger, und bisher beliebte Frameworks sind veraltet. Genau dies ist mit dem Iron Web Framework geschehen, das wir bei der Entwicklung von Exonum verwendet haben.

Actix-web wurde als Ersatz für Eisen gewählt. Weiter werde ich erzählen, wie wir den vorhandenen Code unter Verwendung allgemeiner Programmiertechniken auf eine neue Lösung portiert haben.


Bild von ulleo PD

Wie wir Eisen benutzt haben


Bei Exonum wurde das Eisengerüst ohne Abstraktionen verwendet. Wir haben Handler auf bestimmten Ressourcen installiert, Abfrageparameter durch Parsen von URLs mithilfe von Hilfsmethoden empfangen und das Ergebnis einfach als Zeichenfolge zurückgegeben.

Es sah alles ungefähr so ​​aus:

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"); } 

Zusätzlich wurden einige Middleware-Add-Ons in Form von CORS-Headern verwendet. Um alle Handler in einer einzigen API zu kombinieren, haben wir mount verwendet.

Warum musstest du ihn verlassen?


Eisen war ein gutes Arbeitstier mit vielen Ergänzungen. Es wurde jedoch in jenen fernen Zeiten geschrieben, als Projekte wie Futures und Tokio nicht existierten.

Die Eisenarchitektur ermöglicht eine synchrone Anforderungsverarbeitung, sodass sie mit einer großen Anzahl gleichzeitig geöffneter Verbindungen problemlos auf die Schulterblätter passt. Damit Eisen skalierbar wird, muss es asynchron gemacht werden. Um dies zu tun, war es notwendig, das gesamte Framework zu überdenken und neu zu schreiben, aber die Entwickler gaben die Arbeit daran allmählich auf.

Warum wir zu actix-web gewechselt sind


Dies ist ein beliebtes Framework, das in TechEmpower-Benchmarks einen hohen Stellenwert einnimmt . Gleichzeitig entwickelt er sich im Gegensatz zu Iron aktiv weiter. Actix-web verfügt über eine gut gestaltete API und eine qualitativ hochwertige Implementierung, die auf dem Actix Actor Framework basiert. Anforderungen werden vom Thread-Pool asynchron verarbeitet. Wenn die Verarbeitung zu Panik führt, wird der Akteur automatisch neu gestartet.

Natürlich hatte actix-web Fehler, zum Beispiel enthielt es eine große Menge an unsicherem Code. Aber später wurde es in Safe Rust umgeschrieben, wodurch dieses Problem gelöst wurde.

Die Umstellung auf actix löste das Stabilitätsproblem. Das Eisen-Backend könnte durch eine große Anzahl von Verbindungen unterbrochen werden. Im Allgemeinen ist die neue API eine einfachere, produktivere und einheitlichere Lösung. Es wird für Benutzer und Entwickler einfacher, die Softwareschnittstelle zu verwenden, und ihre Geschwindigkeit wird zunehmen.

Was wir von einem Webframework wollen


Für uns war es wichtig, Iron nicht nur auf actix-web umzustellen, sondern eine Grundlage für die Zukunft zu schaffen - eine neue API-Architektur für die Abstraktion von einem bestimmten Webframework zu erarbeiten. Auf diese Weise können Sie Handler erstellen, fast ohne über Webspezifikationen nachzudenken, und diese an ein beliebiges Backend übertragen. Dies kann durch Schreiben eines Frontends erfolgen, das mit grundlegenden Typen und Typen arbeitet.

Um zu verstehen, wie dieses Frontend aussieht, definieren wir, was eine HTTP-API ist:

  • Anfragen werden ausschließlich von Clients gestellt und der Server beantwortet sie nur (er fungiert nicht als Initiator).
  • Anfragen werden gelesen und geändert.
  • Als Ergebnis der Abfrage gibt der Server eine Antwort zurück, die die gewünschten Daten zum Erfolg und im Fehlerfall Informationen dazu enthält.

Wenn wir alle Abstraktionsebenen analysieren, stellt sich heraus, dass jede HTTP-Anforderung nur ein Funktionsaufruf ist:

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

Alles andere kann als Erweiterung dieser Basisentität betrachtet werden. Um die spezifische Implementierung des Webframeworks zu ignorieren, müssen wir daher Handler in einem ähnlichen Stil wie im obigen Beispiel schreiben.

Endpunktmerkmal für die allgemeine Verarbeitung von HTTP-Anforderungen

Sie können den einfachsten und direktesten Weg gehen und ein Endpunktmerkmal deklarieren.
Beschreibung der Implementierung spezifischer Abfragen:

 // ,   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>; } 

Danach müssen Sie diesen Handler in einem bestimmten Framework implementieren. Nehmen wir an, für actix-web sieht es ungefähr so ​​aus:

 //    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>, } 

Sie können Strukturen verwenden, um Anforderungsparameter durch den Kontext zu übergeben. Actix-web kann Parameter mithilfe von serde automatisch deserialisieren. Zum Beispiel wird a = 15 & b = Hallo in eine Struktur der folgenden Form deserialisiert:

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

Dies stimmt mit dem zugeordneten Anforderungstyp aus dem Endpunktmerkmal überein.

Schreiben wir nun einen Adapter, der eine bestimmte Endpoint-Implementierung in RequstHandler für actix-web umschließt. Bitte beachten Sie, dass dabei Informationen zu Anforderungs- und Antworttypen verloren gehen. Diese Technik wird als Typlöschung bezeichnet. Seine Aufgabe ist es, statische Planung in dynamische umzuwandeln.

 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>, } } } 

In dieser Phase können Sie Handler für POST-Anforderungen hinzufügen und stoppen, da wir ein Merkmal erstellt haben, das von den Implementierungsdetails abstrahiert ist. Es ist jedoch immer noch nicht zu ergonomisch.

Typ Probleme

Beim Schreiben eines Handlers wird viel Hilfscode generiert:

 //    . 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); 

Idealerweise möchte ich in der Lage sein, einen normalen Abschluss als Handler zu übergeben und das Ausmaß des Syntaxrauschens um eine Größenordnung zu reduzieren:

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

Ich werde später darüber sprechen, wie das geht.

Einfaches Eintauchen in die allgemeine Programmierung


Wir müssen die Fähigkeit erkennen, automatisch einen Adapter zu generieren, der Endpoint mit den richtigen zugeordneten Typen implementiert. In diesem Fall wird der Eingabe nur ein Abschluss mit dem HTTP-Anforderungshandler zugeführt.

Es gibt verschiedene Arten von Argumenten und das Ergebnis des Abschlusses. Daher müssen Sie hier mit der Methodenüberladung arbeiten. Rust unterstützt das direkte Überladen nicht, ermöglicht jedoch die Emulation mithilfe der Merkmale Into und From.

Darüber hinaus muss der Rückgabewerttyp des Abschlusses nicht mit dem Rückgabewert der Endpoint-Implementierung übereinstimmen. Um diesen Typ zu manipulieren, muss er aus dem Typ des empfangenen Abschlusses extrahiert werden.

Extraktion von Typen aus dem Fn-Typ

In Rust hat jeder Abschluss einen eigenen Typ, der nicht explizit in das Programm geschrieben werden kann. Um Verschlüsse zu manipulieren, gibt es einen Typ Fn. Es enthält eine Funktionssignatur mit Argumenttypen und Rückgabewert. Das individuelle Extrahieren ist jedoch nicht so einfach.

Die Hauptidee besteht darin, eine Hilfsstruktur der folgenden Form zu verwenden:

 ///       F: Fn(A) -> B. struct SimpleExtractor<A, B, F> {   //   .   inner: F,   _a: PhantomData<A>,   _b: PhantomData<B>, } 

Wir sind gezwungen, PhantomData zu verwenden, da für Rust alle Generalisierungsparameter in der Strukturdefinition enthalten sein müssen. Die spezifische Art des Verschlusses oder der Funktion F ist jedoch nicht verallgemeinert (obwohl sie den verallgemeinerten Typ Fn implementiert). Die Typparameter A und B werden darin nicht direkt verwendet.

Es ist diese Einschränkung des Rust-Typ-Systems, die keine einfachere Strategie zulässt - das Endpunkt-Merkmal für Schließungen direkt zu implementieren:

 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> { // ... } } 

Der Compiler gibt in diesem Fall einen Fehler zurück:

 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 

Die Hilfsstruktur von SimpleExtractor ermöglicht die Beschreibung der From-Transformation. Sie können jede Funktion speichern und die Typen ihrer Argumente extrahieren:

 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, } } } 

Der folgende Code wird erfolgreich kompiliert:

 #[derive(Deserialize)] struct Query { a: i32, b: String, }; //   . fn my_handler(_: &Context, q: Query) -> String { format!("{} has {} apples.", qb, qa) } let fn_extractor = SimpleExtractor::from(my_handler); //  . let c = 15; let my_closure = |_: &Context, q: Query| -> String { format!("{} has {} apples, but Alice has {}", qb, qa, c) }; let closure_extractor = SimpleExtractor::from(my_closure); 

Spezialisierung und Markertypen

Jetzt haben wir eine Funktion mit explizit parametrisierten Argumenttypen, die anstelle des Endpunktmerkmals verwendet werden kann. Zum Beispiel können wir die Konvertierung von SimpleExtractor zu RequestHandler einfach implementieren. Dies ist jedoch keine vollständige Lösung. Wir müssen auch irgendwie zwischen GET-Anforderungshandlern und POST-Anforderungen auf Typebene unterscheiden (und zwischen synchronen und asynchronen Handlern). Die sogenannten Markertypen helfen uns dabei.

Zuerst schreiben wir SimpleExtractor neu, damit zwischen synchronen und asynchronen Ergebnissen unterschieden werden kann. Gleichzeitig implementieren wir das From-Merkmal für jeden Fall. Beachten Sie, dass Merkmale für bestimmte Varianten verallgemeinerter Strukturen implementiert werden können.

 ///   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, } } } 

Jetzt müssen wir eine Struktur deklarieren, in der der Anforderungshandler mit seinem Namen und seiner Vielfalt kombiniert werden soll:

 #[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {   ///  .   pub name: String,   ///    .   pub inner: With<Q, I, R, F>,   ///  .   _kind: PhantomData<K>, } 

Nachdem Sie mehrere leere Strukturen deklariert haben, die als Markertypen fungieren. Mit Markern können Sie für jeden Handler einen eigenen Konvertierungscode für den zuvor beschriebenen RequestHandler implementieren.

 /// ,    .  HTTP   GET-. pub struct Immutable; /// ,   .  HTTP   POST, PUT, UPDATE ///    ,        POST. pub struct Mutable; 

Jetzt können wir vier verschiedene Implementierungen des From-Typs für alle Kombinationen der R- und K-Template-Parameter definieren (Rückgabewert des Handlers und Typ der Anforderung).

 //     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>, } } } 

"Fassade" für das Backend

Für all dies bleibt nun eine „Fassade“ zu schreiben, die Verschlüsse nimmt und sie dem entsprechenden Backend hinzufügt. In unserem Fall gibt es nur ein Backend - actix-web - aber hinter der Fassade können Sie zusätzliche Implementierungen ausblenden, die Sie mögen, z. B. einen Swagger-Spezifikationsgenerator.

 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 } 

Beachten Sie, wie die Arten von Anforderungsparametern, die Art des Ergebnisses sowie die Synchronität / Asynchronität des Handlers automatisch von seiner Signatur abgeleitet werden. Darüber hinaus müssen Sie den Namen der Anforderung sowie deren Typ explizit angeben.

Nachteile des Ansatzes


Dieser Ansatz hat immer noch seine Nachteile. Insbesondere sollten endpoint und endpoint_mut die Besonderheiten der Implementierung spezifischer Backends kennen . Dies erlaubt uns nicht, Backends im laufenden Betrieb hinzuzufügen, aber solche Funktionen werden selten benötigt.

Ein weiteres Problem besteht darin, dass Sie ohne zusätzliche Argumente keine Spezialisierung für einen Handler definieren können. Mit anderen Worten, wenn wir den folgenden Code schreiben, wird er nicht kompiliert, da er mit der vorhandenen verallgemeinerten Implementierung in Konflikt steht:

 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, } } } 

Daher sollten Anforderungen ohne Parameter weiterhin eine JSON-Nullzeichenfolge akzeptieren, die auf () deserialisiert ist. Dieses Problem könnte durch Spezialisierung auf den Stil von C ++ gelöst werden. Bisher ist es jedoch nur in der nächtlichen Version des Compilers verfügbar und es ist unklar, wann es sich "stabilisiert".

Ebenso können Sie den Typ des Rückgabewerts nicht spezialisieren. Selbst wenn die Anforderung dies nicht impliziert, wird JSON immer mit null zurückgegeben.

Das Dekodieren von URL-Abfragen in GET-Anforderungen unterwirft auch einige nicht offensichtliche Einschränkungen für die Art der Parameter, die jedoch bereits Merkmale der Implementierung von serde-urlencoded sind.

Fazit


Daher haben wir eine API implementiert, mit der Sie einfach und unkompliziert Handler erstellen können, fast ohne über Webspezifikationen nachzudenken. Später können sie auf beliebige Backends übertragen werden oder sogar mehrere Backends gleichzeitig verwenden.

Source: https://habr.com/ru/post/de429450/


All Articles