Ekosistem Rust masih tumbuh. Akibatnya, perpustakaan baru dengan fungsionalitas yang ditingkatkan sering dirilis ke komunitas pengembang, sementara perpustakaan yang lebih lama menjadi usang. Ketika kami awalnya merancang Exonum, kami menggunakan kerangka kerja web Iron. Dalam artikel ini, kami menjelaskan bagaimana kami porting kerangka kerja Exonum ke actix-web menggunakan pemrograman generik.
Eksonum dengan zat besi
Dalam platform Exonum, kerangka besi digunakan tanpa abstraksi. Kami memasang penangan untuk sumber daya tertentu dan memperoleh parameter permintaan dengan mem-parsing URL menggunakan metode tambahan; hasilnya dikembalikan hanya dalam bentuk string.
Prosesnya tampak (kurang-lebih) seperti berikut:
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"); }
Selain itu, kami menggunakan beberapa plugin middleware dalam bentuk header CORS. Kami menggunakan mount untuk menggabungkan semua penangan menjadi satu API.
Keputusan Kami untuk Beralih dari Besi
Besi adalah perpustakaan yang bagus, dengan banyak plugin. Namun, itu ditulis pada hari-hari ketika proyek seperti futures dan
tokio tidak ada.
Arsitektur Besi melibatkan pemrosesan permintaan sinkron, yang dapat dengan mudah dipengaruhi oleh sejumlah besar koneksi yang terbuka secara bersamaan. Agar skalabel, Iron harus menjadi asinkron, yang akan melibatkan pemikiran ulang dan penulisan ulang seluruh kerangka kerja. Sebagai hasilnya, kami telah melihat perubahan bertahap dari penggunaan Besi oleh insinyur perangkat lunak.
Mengapa Kami Memilih Actix-Web
Actix-web adalah kerangka kerja populer yang
berperingkat tinggi pada
tolok ukur TechEmpower . Ini memiliki komunitas pengembang aktif, tidak seperti Iron, dan memiliki API yang dirancang dengan baik dan implementasi berkualitas tinggi berdasarkan kerangka aktor actix. Permintaan diproses secara tidak sinkron oleh kumpulan utas; jika meminta pemrosesan panic, aktor secara otomatis dihidupkan ulang.
Sebelumnya, muncul kekhawatiran bahwa actix-web berisi banyak kode tidak aman. Namun, jumlah kode tidak aman berkurang secara signifikan ketika kerangka kerja itu ditulis ulang dalam bahasa pemrograman yang aman - Rust. Insinyur Bitfury telah meninjau kode ini sendiri dan merasa percaya diri dalam stabilitas jangka panjangnya.
Untuk kerangka kerja Exonum, beralih ke actix memecahkan masalah stabilitas operasi. Kerangka kerja besi dapat gagal jika ada sejumlah besar koneksi. Kami juga menemukan bahwa API actix-web lebih sederhana, lebih produktif, dan lebih menyatu. Kami yakin bahwa pengguna dan pengembang akan lebih mudah menggunakan antarmuka pemrograman Exonum, yang sekarang dapat beroperasi lebih cepat berkat desain actix-web.
Apa Yang Kami Tuntut dari Kerangka Kerja Web
Selama proses ini kami menyadari bahwa penting bagi kami untuk tidak hanya mengubah kerangka kerja, tetapi juga untuk merancang arsitektur API baru yang independen dari kerangka kerja web tertentu. Arsitektur seperti itu akan memungkinkan untuk membuat penangan, dengan sedikit atau tanpa keprihatinan tentang spesifik web, dan mentransfernya ke backend apa pun. Konsepsi ini dapat diimplementasikan dengan menulis sebuah frontend yang akan menerapkan tipe dan sifat dasar.
Untuk memahami seperti apa tampilan frontend ini, mari tentukan apa sebenarnya API HTTP itu:
- Permintaan dibuat secara eksklusif oleh klien; server hanya merespons mereka (server tidak memulai permintaan).
- Meminta membaca data atau mengubah data.
- Sebagai hasil dari pemrosesan permintaan, server mengembalikan respons, yang berisi data yang diperlukan, jika berhasil; atau informasi tentang kesalahan, jika terjadi kegagalan.
Jika kita menganalisis semua lapisan abstraksi, ternyata setiap permintaan HTTP hanyalah panggilan fungsi:
fn request(context: &ServiceContext, query: Query) -> Result<Response, ServiceError>
Segala sesuatu yang lain dapat dianggap sebagai perpanjangan dari entitas dasar ini. Jadi, untuk menjadi independen dari implementasi spesifik kerangka kerja web, kita perlu menulis penangan dengan gaya yang mirip dengan contoh di atas.
Ciri `Endpoint` untuk Pemrosesan Generik permintaan-HTTP
Pendekatan yang paling sederhana dan langsung adalah mendeklarasikan sifat `Endpoint`, yang menggambarkan implementasi permintaan spesifik:
Sekarang kita perlu mengimplementasikan penangan ini dalam kerangka kerja tertentu. Sebagai contoh, di actix-web terlihat seperti berikut:
Kita dapat menggunakan struktur untuk melewatkan parameter permintaan melalui konteks. Actix-web dapat secara otomatis deserialize parameter menggunakan serde. Sebagai contoh, a = 15 & b = hello deserialized ke dalam struktur seperti ini:
#[derive(Deserialize)] struct SimpleQuery { a: i32, b: String, }
Fungsi deserialisasi ini sangat sesuai dengan tipe Permintaan yang terkait dari sifat `Endpoint`.
Selanjutnya, mari buat sebuah adaptor yang membungkus implementasi spesifik dari `Endpoint` ke dalam RequestHandler untuk actix-web. Perhatikan fakta bahwa saat melakukannya, informasi tentang jenis Permintaan dan Respons menghilang. Teknik ini disebut tipe erasure - mengubah pengiriman statis menjadi dinamis.
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>, } } }
Pada tahap ini, cukup dengan menambahkan penangan untuk permintaan POST, karena kami telah menciptakan sifat yang independen dari detail implementasi. Namun, kami menemukan bahwa solusi ini tidak cukup canggih.
Kekurangan dari Sifat `Endpoint`
Sejumlah besar kode bantu dihasilkan ketika pawang ditulis:
Idealnya, kita harus mampu melewati penutupan sederhana sebagai seorang pawang, sehingga secara signifikan mengurangi jumlah kebisingan sintaksis.
let elements = elements.clone(); actix_backend.endpoint("/v1/elements_count", move || { Ok(elements.borrow().len()) });
Di bawah ini kita akan membahas bagaimana ini bisa dilakukan.
Perendaman Ringan ke Pemrograman Generik
Kita perlu menambahkan kemampuan untuk secara otomatis menghasilkan adaptor yang mengimplementasikan sifat `Endpoint` dengan tipe terkait yang benar. Input hanya akan terdiri dari penutupan dengan penangan permintaan HTTP.
Argumen dan hasil penutupan dapat memiliki tipe yang berbeda, jadi kami harus bekerja dengan metode kelebihan beban di sini. Karat tidak mendukung kelebihan beban secara langsung tetapi memungkinkan untuk ditiru menggunakan sifat `Into` dan` From`.
Selain itu, tipe nilai penutupan yang dikembalikan tidak harus cocok dengan nilai yang dikembalikan dari implementasi `Endpoint`. Untuk memanipulasi tipe ini, itu harus diekstraksi dari tipe penutupan yang diterima.
Mengambil Jenis dari Karakter `Fn`
Di Rust, setiap penutupan memiliki tipe uniknya sendiri, yang tidak dapat secara eksplisit ditunjukkan dalam program. Untuk manipulasi dengan penutupan, kami menggunakan sifat `Fn`. Ciri tersebut berisi tanda tangan dari fungsi dengan jenis argumen dan nilai yang dikembalikan, namun, mengambil elemen-elemen ini secara terpisah tidak mudah dilakukan.
Gagasan utamanya adalah menggunakan struktur bantu dari bentuk berikut:
Kita harus menggunakan PhantomData, karena Rust mengharuskan semua parameter generik ditunjukkan dalam definisi struktur. Namun, tipe penutupan atau fungsi F itu sendiri bukan yang generik (meskipun menerapkan sifat `Fn` generik). Parameter tipe A dan B tidak digunakan secara langsung.
Pembatasan sistem tipe Rust inilah yang menghalangi kami untuk menerapkan strategi yang lebih sederhana dengan menerapkan sifat `Endpoint` secara langsung untuk penutupan:
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> {
Dalam kasus di atas, kompiler mengembalikan kesalahan:
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
Struktur bantu SimpleExtractor memungkinkan untuk mendeskripsikan konversi `Dari`. Konversi ini memungkinkan kami untuk menyimpan fungsi apa pun dan mengekstraksi jenis argumennya:
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, } } }
Kode berikut berhasil dikompilasi:
#[derive(Deserialize)] struct Query { a: i32, b: String, };
Jenis Spesialisasi dan Penanda
Sekarang kita memiliki fungsi dengan tipe argumen parameter secara eksplisit, yang dapat digunakan sebagai ganti sifat `Endpoint`. Misalnya, kita dapat dengan mudah mengimplementasikan konversi dari SimpleExtractor ke RequestHandler. Meski begitu, ini bukan solusi lengkap. Kita perlu entah bagaimana membedakan antara penangan untuk permintaan GET dan POST pada tingkat tipe (dan antara penangan sinkron dan asinkron). Dalam tugas ini, jenis marker akan membantu kami.
Pertama, mari kita menulis ulang SimpleExtractor sehingga dapat membedakan antara hasil sinkron dan asinkron. Pada saat yang sama, kami akan menerapkan sifat `Dari` untuk masing-masing kasus. Perhatikan bahwa sifat dapat diimplementasikan untuk varian spesifik dari struktur generik.
Sekarang kita perlu mendeklarasikan struktur yang akan menggabungkan penangan permintaan dengan nama dan jenisnya:
#[derive(Debug)] pub struct NamedWith<Q, I, R, F, K> {
Selanjutnya, kami mendeklarasikan beberapa struktur kosong yang akan bertindak sebagai tipe marker. Marker akan memungkinkan kita untuk menerapkan untuk masing-masing penangan kode mereka sendiri untuk mengubah penangan menjadi RequestHandler yang dijelaskan sebelumnya.
Sekarang kita dapat mendefinisikan empat implementasi yang berbeda dari sifat `Dari` untuk semua kombinasi parameter template R dan K (nilai yang dikembalikan dari penangan dan jenis permintaan).
Fasad untuk backend
Langkah terakhir adalah untuk merancang fasad yang akan menerima penutupan dan menambahkannya ke dalam backend yang sesuai. Dalam kasus yang diberikan, kami memiliki backend tunggal - actix-web. Namun, ada potensi implementasi tambahan di balik fasad. Misalnya: generator spesifikasi Swagger.
pub struct ServiceApiScope { actix_backend: actix::ApiBuilder, } impl ServiceApiScope {
Perhatikan bagaimana jenis parameter permintaan, jenis hasil permintaan, dan sinkronisasi / asinkron dari pawang diturunkan secara otomatis dari tanda tangannya. Selain itu, kita perlu secara spesifik menentukan nama dan jenis permintaan.
Kerugian dari pendekatan
Pendekatan yang dijelaskan di atas, meskipun cukup efektif, memiliki kelemahan. Secara khusus,
metode endpoint dan endpoint_mut harus mempertimbangkan kekhasan implementasi backend spesifik . Pembatasan ini mencegah kami menambahkan backend saat bepergian, meskipun fungsi ini jarang diperlukan.
Masalah lain adalah bahwa
kita tidak dapat menentukan spesialisasi dari seorang pawang tanpa argumen tambahan . Dengan kata lain, jika kita menulis kode berikut, itu tidak akan dikompilasi karena bertentangan dengan implementasi generik yang ada:
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, } } }
Akibatnya, permintaan yang tidak memiliki parameter apa pun harus tetap menerima string JSON null, yang dideserialisasi ke (). Masalah ini dapat diselesaikan dengan spesialisasi dalam gaya C ++, tetapi untuk sekarang ini hanya tersedia dalam versi malam dari kompiler dan tidak jelas kapan akan menjadi fitur yang stabil.
Demikian pula,
jenis nilai yang dikembalikan tidak dapat dikhususkan . Bahkan jika permintaan tidak menyiratkan tipe tertentu dari nilai yang dikembalikan, masih akan melewati JSON dengan nol.
Decoding kueri URL dalam permintaan GET juga membebankan beberapa pembatasan yang tidak jelas pada jenis parameter , tetapi masalah ini lebih berkaitan dengan kekhasan implementasi serde-urlencoded.
Kesimpulan
Seperti yang dijelaskan di atas, kami telah mengimplementasikan API yang ditingkatkan, yang memungkinkan pembuatan handler yang sederhana dan jelas, tanpa perlu khawatir tentang spesifikasi web. Penangan ini dapat bekerja dengan backend apa pun atau bahkan dengan beberapa backend secara bersamaan.