Das Rust-Ökosystem wächst weiter. Infolgedessen werden häufig neue Bibliotheken mit verbesserter Funktionalität für die Entwicklergemeinschaft freigegeben, während ältere Bibliotheken veraltet sind. Als wir Exonum entworfen haben, haben wir das Iron Web-Framework verwendet. In diesem Artikel beschreiben wir, wie wir das Exonum-Framework mithilfe generischer Programmierung auf actix-web portiert haben.
Exonum auf Eisen
In der Exonum-Plattform wurde das Iron-Framework ohne Abstraktionen verwendet. Wir haben Handler für bestimmte Ressourcen installiert und Anforderungsparameter erhalten, indem wir URLs mithilfe von Hilfsmethoden analysiert haben. Das Ergebnis wurde einfach in Form einer Zeichenfolge zurückgegeben.
Der Prozess sah (ungefähr) wie folgt 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 haben wir einige Middleware-Plugins in Form von CORS-Headern verwendet. Wir haben mount verwendet, um alle Handler in einer einzigen API zusammenzuführen.
Unsere Entscheidung, uns von Eisen zu entfernen
Iron war eine gute Bibliothek mit vielen Plugins. Es wurde jedoch in den Tagen geschrieben, als es solche Projekte wie Futures und
Tokio nicht gab.
Die Architektur von Iron beinhaltet die synchrone Verarbeitung von Anforderungen, die leicht durch eine große Anzahl gleichzeitig geöffneter Verbindungen beeinflusst werden kann. Um skalierbar zu sein, musste Iron asynchron werden, was das Überdenken und Umschreiben des gesamten Frameworks beinhalten würde. Infolgedessen haben wir eine allmähliche Abkehr von der Verwendung von Eisen durch Softwareentwickler festgestellt.
Warum wir uns für Actix-Web entschieden haben
Actix-Web ist ein beliebtes Framework, das bei
TechEmpower-Benchmarks einen hohen
Stellenwert einnimmt . Im Gegensatz zu Iron verfügt es über eine aktive Entwickler-Community, 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 von Anforderungen in Panik gerät, wird der Akteur automatisch neu gestartet.
Zuvor wurden Bedenken geäußert, dass actix-web viel unsicheren Code enthält. Die Menge an unsicherem Code wurde jedoch erheblich reduziert, als das Framework in einer sicheren Programmiersprache - Rust - neu geschrieben wurde. Die Ingenieure von Bitfury haben diesen Code selbst überprüft und sind zuversichtlich, dass er langfristig stabil ist.
Für das Exonum-Framework löste die Umstellung auf actix das Problem der Betriebsstabilität. Das Eisengerüst könnte versagen, wenn es eine große Anzahl von Verbindungen gäbe. Wir haben auch festgestellt, dass die actix-web-API einfacher, produktiver und einheitlicher ist. Wir sind zuversichtlich, dass Benutzer und Entwickler die Exonum-Programmierschnittstelle, die dank des actix-web-Designs jetzt schneller arbeiten kann, leichter nutzen können.
Was wir von einem Web Framework benötigen
Während dieses Prozesses wurde uns klar, dass es für uns wichtig ist, nicht nur Frameworks zu verschieben, sondern auch eine neue API-Architektur zu entwickeln, die von einem bestimmten Webframework unabhängig ist. Eine solche Architektur würde es ermöglichen, Handler zu erstellen, ohne sich um Webspezifikationen zu kümmern, und diese auf ein beliebiges Backend zu übertragen. Diese Konzeption kann implementiert werden, indem ein Frontend geschrieben wird, das grundlegende Typen und Merkmale anwendet.
Um zu verstehen, wie dieses Frontend aussehen muss, definieren wir, was eine HTTP-API wirklich ist:
- Anfragen werden ausschließlich von Kunden gestellt; Der Server antwortet nur auf sie (der Server initiiert keine Anforderungen).
- Anforderungen lesen entweder Daten oder ändern Daten.
- Als Ergebnis der Anforderungsverarbeitung gibt der Server im Erfolgsfall eine Antwort zurück, die die erforderlichen Daten enthält. oder Informationen über den Fehler im Fehlerfall.
Wenn wir alle Abstraktionsschichten analysieren wollen, 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 grundlegenden Entität betrachtet werden. Um von einer bestimmten Implementierung eines Webframeworks unabhängig zu sein, müssen wir daher Handler in einem ähnlichen Stil wie im obigen Beispiel schreiben.
Merkmal `Endpunkt` für die generische Verarbeitung von HTTP-Anfragen
Der einfachste und einfachste Ansatz wäre die Deklaration des Endpunkt-Merkmals, das die Implementierung spezifischer Anforderungen beschreibt:
Jetzt müssen wir diesen Handler in einem bestimmten Framework implementieren. In actix-web sieht es beispielsweise folgendermaßen aus:
Wir 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 wie diese deserialisiert:
#[derive(Deserialize)] struct SimpleQuery { a: i32, b: String, }
Diese Deserialisierungsfunktionalität stimmt gut mit dem zugehörigen Typ Anforderung vom Merkmal "Endpunkt" überein.
Als nächstes entwickeln wir einen Adapter, der eine bestimmte Implementierung von `Endpoint` in einen RequestHandler für actix-web einbindet. Beachten Sie, dass dabei die Informationen zu den Anforderungs- und Antworttypen verschwinden. Diese Technik wird als Typlöschung bezeichnet - sie wandelt statisches Dispatching in ein dynamisches um.
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>, } } }
Zu diesem Zeitpunkt würde es ausreichen, nur Handler für POST-Anforderungen hinzuzufügen, da wir ein Merkmal erstellt haben, das von den Implementierungsdetails unabhängig ist. Wir haben jedoch festgestellt, dass diese Lösung nicht weit genug fortgeschritten ist.
Die Nachteile des Endpunktmerkmals
Beim Schreiben eines Handlers wird eine große Menge an Hilfscode generiert:
Idealerweise müssen wir in der Lage sein, einen einfachen Verschluss als Handler zu übergeben, wodurch das syntaktische Rauschen erheblich reduziert wird.
let elements = elements.clone(); actix_backend.endpoint("/v1/elements_count", move || { Ok(elements.borrow().len()) });
Im Folgenden werden wir diskutieren, wie dies getan werden kann.
Leichtes Eintauchen in die generische Programmierung
Wir müssen die Möglichkeit hinzufügen, automatisch einen Adapter zu generieren, der das Merkmal "Endpunkt" mit den richtigen zugehörigen Typen implementiert. Die Eingabe besteht nur aus einem Abschluss mit einem HTTP-Anforderungshandler.
Argumente und das Ergebnis des Abschlusses können unterschiedliche Typen haben, daher müssen wir hier mit Methoden arbeiten, die überladen sind. Rust unterstützt das direkte Überladen nicht, ermöglicht jedoch die Emulation mit den Merkmalen "Into" und "From".
Außerdem muss der zurückgegebene Typ des Abschlusswerts nicht mit dem zurückgegebenen Wert der Endpoint-Implementierung übereinstimmen. Um diesen Typ zu manipulieren, muss er aus dem Typ des empfangenen Abschlusses extrahiert werden.
Abrufen von Typen aus dem Merkmal "Fn"
In Rust hat jeder Verschluss seinen eigenen eindeutigen Typ, der im Programm nicht explizit angegeben werden kann. Für Manipulationen mit Verschlüssen verwenden wir das Merkmal "Fn". Das Merkmal enthält die Signatur der Funktion mit den Typen der Argumente und des zurückgegebenen Werts. Das separate Abrufen dieser Elemente ist jedoch nicht einfach.
Die Hauptidee besteht darin, eine Hilfsstruktur der folgenden Form zu verwenden:
Wir müssen PhantomData verwenden, da Rust erfordert, dass alle generischen Parameter in der Definition der Struktur angegeben werden. Die Art des Verschlusses oder der Funktion F selbst ist jedoch keine generische (obwohl sie ein generisches "Fn" -Eigenschaft implementiert). Die Typparameter A und B werden darin nicht direkt verwendet.
Es ist diese Einschränkung des Systems vom Typ Rust, die uns daran hindert, eine einfachere Strategie anzuwenden, indem das Merkmal "Endpunkt" direkt für Schließungen implementiert wird:
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> {
Im obigen Fall gibt der Compiler 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 SimpleExtractor ermöglicht es, die Konvertierung von `From` zu beschreiben. Diese Konvertierung ermöglicht es uns, jede Funktion zu speichern und die Typen ihrer Argumente zu 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, };
Spezialisierung und Markertypen
Jetzt haben wir eine Funktion mit explizit parametrisierten Argumenttypen, die anstelle des Merkmals "Endpoint" verwendet werden kann. Zum Beispiel können wir die Konvertierung von SimpleExtractor in RequestHandler einfach implementieren. Dies ist jedoch keine vollständige Lösung. Wir müssen irgendwie zwischen den Handlern für GET- und POST-Anforderungen auf Typebene (und zwischen synchronen und asynchronen Handlern) unterscheiden. Bei dieser Aufgabe helfen uns Markertypen.
Lassen Sie uns zunächst SimpleExtractor neu schreiben, damit zwischen synchronen und asynchronen Ergebnissen unterschieden werden kann. Gleichzeitig werden wir für jeden Fall das Merkmal "Von" implementieren. Beachten Sie, dass Merkmale für bestimmte Varianten generischer Strukturen implementiert werden können.
Jetzt müssen wir die Struktur deklarieren, die den Anforderungshandler mit seinem Namen und Typ kombiniert:
#[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {
Als nächstes deklarieren wir mehrere leere Strukturen, die als Markertypen fungieren. Mit Markern können wir für jeden Handler einen eigenen Code implementieren, um den Handler in den zuvor beschriebenen RequestHandler zu konvertieren.
Jetzt können wir vier verschiedene Implementierungen des Merkmals "Von" für alle Kombinationen der Vorlagenparameter R und K definieren (den zurückgegebenen Wert des Handlers und den Typ der Anforderung).
Fassade für das Backend
Der letzte Schritt besteht darin, eine Fassade zu entwerfen, die Verschlüsse akzeptiert und diese in das entsprechende Backend einfügt. Im gegebenen Fall haben wir ein einziges Backend - actix-web. Hinter der Fassade besteht jedoch das Potenzial einer zusätzlichen Umsetzung. Zum Beispiel: ein Generator mit Swagger-Spezifikationen.
pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope {
Beachten Sie, wie die Typen der Anforderungsparameter, der Typ des Anforderungsergebnisses und die Synchronität / Asynchronität des Handlers automatisch von seiner Signatur abgeleitet werden. Zusätzlich müssen wir den Namen und den Typ der Anfrage explizit angeben.
Nachteile des Ansatzes
Der oben beschriebene Ansatz hat, obwohl er ziemlich effektiv ist, seine Nachteile. Insbesondere
sollten die Methoden endpoint und endpoint_mut die Besonderheiten der Implementierung bestimmter Backends berücksichtigen . Diese Einschränkung verhindert, dass wir unterwegs Backends hinzufügen können, obwohl diese Funktionalität selten erforderlich ist.
Ein weiteres Problem ist, dass
wir die Spezialisierung eines Handlers nicht ohne zusätzliche Argumente definieren können . Mit anderen Worten, wenn wir den folgenden Code schreiben, wird er nicht kompiliert, da er im Widerspruch zur vorhandenen generischen Implementierung 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 müssen Anforderungen, die keine Parameter haben, weiterhin die JSON-Zeichenfolge null akzeptieren, die in () deserialisiert wird. Dieses Problem könnte durch Spezialisierung auf C ++ - Stil gelöst werden. Derzeit ist es jedoch nur in der nächtlichen Version des Compilers verfügbar und es ist nicht klar, wann es zu einer stabilen Funktion wird.
Ebenso kann
der Typ des zurückgegebenen Werts nicht spezialisiert werden . Auch wenn die Anforderung keinen bestimmten Typ des zurückgegebenen Werts impliziert, wird JSON mit null übergeben.
Das Dekodieren der URL-Abfrage in GET-Anforderungen führt auch zu einigen nicht offensichtlichen Einschränkungen für den Parametertyp . Dieses Problem bezieht sich jedoch eher auf die Besonderheiten der serde-urlencodierten Implementierung.
Fazit
Wie oben beschrieben, haben wir eine verbesserte API implementiert, die eine einfache und übersichtliche Erstellung von Handlern ermöglicht, ohne sich um Webspezifikationen kümmern zu müssen. Diese Handler können mit jedem Backend oder sogar mit mehreren Backends gleichzeitig arbeiten.