【譯】教你用16個小時從0構建一個Rust應用

我們在2019年的最後兩天,參加了Prodigy Education舉辦的黑客馬拉松,許多團隊聚在一起努力將他們的想法變成現實。

我們之中有的人只是單純爲了好玩,有的是想學一些新的知識,還有些人可能是想證明一些概念或想法。

我在過去幾周總是被動的獲取Rust相關信息或使用Rust的代碼,因此我認爲hackathon是一次學習Rust的絕佳時機。

hackathon的時間緊迫性使我更加快速的去學習,同時也會去解決現實世界的一些問題。

爲什麼是Rust

在這裏插入圖片描述

在我職業生涯的前10年中,有8年都在使用C和C++。

從好的方面來講,我喜歡像C++這樣可以提供靜態類型的語言,因爲它能在編譯期就能夠早早的發現錯誤。

我個人對於C++的一些看法是:

  • 工程師很容易搬起石頭砸自己的腳
  • 作爲一門編程語言,它已經非常臃腫且複雜
  • 缺乏良好的、標準的廣泛適用的包管理系統

自從我改做Web應用以來,一直是做Python和JavaScript開發,使用像Django、Flask和Express這樣的框架。

到目前爲止,我在Python和JavaScript中的開發經驗是,它們可以提供良好的程序迭代和交付速度,但有時會佔用大量的CPU和內存,即使服務是相對空閒的。

我經常發現自己寫好的C++程序,會缺失一些安全性、速度和精簡性。

我想要尋找一種像Rust這樣精簡的、裸機編程語言來開發web應用。

沒有運行時,沒有垃圾回收。直接加載二進制代碼,交給內核執行。

目標

我的目標是完成一個後端由Rust編寫,前端是JavaScript+React完成的類似於S3作爲圖牀的應用程序,用戶可以做以下事情:

  • 瀏覽圖牀中所有的圖片(分頁可選)
  • 上傳圖片
  • 上傳圖片時可以給圖片增加標籤
  • 通過名稱進行查詢或過濾

所有有趣的hackathon項目都有一個名字,所以我決定將這個項目命名爲:

RustIC -> Rust + Image Contents

在這裏插入圖片描述

我認爲如果我做到了以下這些事情,那麼這次hackathon之行對我個人來說就是成功的:

  • 對Rust有一個基本的理解,包括它的類型系統和內存模型
  • 探索S3的對於文件和任意標籤的預簽名鏈接功能
  • 寫出一個可以驗證的功能正常的應用

由於我的主要目標是開發功能,同時兼顧學習。很多代碼是我一邊學一邊寫的,所以代碼組織和效率可能並不是最理想的,因爲這些屬於次要目標。

Rust的原則

在我開始之前,我帶着好奇心去了解了要學習的語言的設計師在創建這門語言時內心的原則是什麼。我找到了一個簡化版本和一個詳細版本

與我在許多博客上讀到的內容相反,Rust是有可能發生內存泄露(循環引用)和之行不安全的操作(unsafe代碼塊中)的,詳細描述在上面的FAQ中。

“We [the language creators] do not intend [for Rust] to be 100% static, 100% safe, 100% reflective.”

在這裏插入圖片描述

從後端開始

Google搜索“Rust web framework“,排在最前面的是Rocket。我進入這個網站,發現文檔的示例都一目瞭然。

有一點需要注意的是Rocket需要Rust的nightly版本,不過在hackathon上這都是小問題。

GitHub的代碼庫中有着非常豐富的例子。完美!

我使用Cargo創建了一個新的項目,在TOML文件中加入了Rocket依賴,然後跟着Rocket的入門指南,寫了第一段代碼:

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

fn main() {
    rocket::ignite().mount("/", routes![index]).launch();
}

對於熟悉Django、Flask、Express等框架等同學來說,這段代碼讀起來非常容易。作爲一名Rocket用戶,你可以使用宏作爲裝飾器來將路由映射到對應的處理函數上。

在編譯時,宏將被擴展。這對開發者是完全透明的。如果你想看擴展後的代碼,可以使用cargo-expand

以下是我在構建Rust應用程序時的一些有趣的或者有挑戰性的亮點:

指定路由響應

我想要以JSON的數據格式返回S3中所有的文件列表。

你可以看到路由關聯的處理函數的代碼決定了響應類型。

設置響應結構非常容易,如果你想要返回JSON格式的數據,並且每個字段都有自己的結構和類型,那對應的就是Rust的struct

所以你應該先定義一個結構體struct(S)來接受響應,並且需要進行標註:

#[derive(Serialize)]

struct(s)被標記了#[derive(Serialize)],因此可以通過rocket_contrib::json::Json將它轉換成JSON

#[derive(Serialize)]
struct BucketContents {
    data: Vec<S3Object>,
}

#[derive(Serialize)]
struct S3Object {
    file_name: String,
    presigned_url: String,
    tags: String,
    e_tag: String, // AWS generated MD5 checksum hash for object
    is_filtered: bool,
}

#[get("/contents?<filter>")]
fn get_bucket_contents(
    filter: Option<&RawStr>
) -> Result<Json<BucketContents>, Custom<String>> {
    // Returns either Ok(Json(BucketContents)) or,
    // a Custom error with a reason
}

處理分段上傳

當我意識到我的前端很有可能使用POST方法上傳格式爲multipart/form-data的表單數據時,我就開始深入研究如何使用Rocket來構建程序了。

不幸的是,Rocket0.4版本不支持multipart,看起來在0.5版本會支持。

這意味着我需要使用multipart crate並集成到Rocket中。最終代碼可以正常運行,但是如果Rocket支持multipart將會使代碼更加簡潔。

#[post("/upload", data = "<data>")]
// signature requires the request to have a `Content-Type`. The preferred way to handle the incoming
// data would have been to use the FromForm trait as described here: https://rocket.rs/v0.4/guide/requests/#forms
// Unfortunately, file uploads are not supported through that mechanism since a file upload is performed as a
// multipart upload, and Rocket does not currently (As of v0.4) support this. 
// https://github.com/SergioBenitez/Rocket/issues/106
fn upload_file(cont_type: &ContentType, data: Data) -> Result<Custom<String>, Custom<String>> {
    // this and the next check can be implemented as a request guard but it seems like just
    // more boilerplate than necessary
    if !cont_type.is_form_data() {
        return Err(Custom(
            Status::BadRequest,
            "Content-Type not multipart/form-data".into()
        ));
    }

    let (_, boundary) = cont_type.params()
                                 .find(|&(k, _)| k == "boundary")
                                 .ok_or_else(
        || Custom(
            Status::BadRequest,
            "`Content-Type: multipart/form-data` boundary param not provided".into()
        )
    )?;

    // The hot mess that ensues is some weird combination of the two links that follow
    // and a LOT of hackery to move data between closures.
    // https://github.com/SergioBenitez/Rocket/issues/106
    // https://github.com/abonander/multipart/blob/master/examples/rocket.rs
    let mut d = Vec::new();
    data.stream_to(&mut d).expect("Unable to read");
    let mut mp = Multipart::with_body(Cursor::new(d), boundary);

    let mut file_name = String::new();
    let mut categories_string = String::new();
    let mut raw_file_data = Vec::new();

    mp.foreach_entry(|mut entry| {
        if *entry.headers.name == *"fileName" { 
            let file_name_vec = entry.data.fill_buf().unwrap().to_owned();
            file_name = from_utf8(&file_name_vec).unwrap().to_string()
        } else if *entry.headers.name == *"tags" {
            let tags_vec = entry.data.fill_buf().unwrap().to_owned();
            categories_string = from_utf8(&tags_vec).unwrap().to_string();
        } else if *entry.headers.name == *"file" {
            raw_file_data = entry.data.fill_buf().unwrap().to_owned()
        }
    }).expect("Unable to iterate");

    let s3_file_manager = s3_interface::S3FileManager::new(None, None, None, None);
    s3_file_manager.put_file_in_bucket(file_name.clone(), raw_file_data);

    let tag_name_val_pairs = vec![("tags".to_string(), categories_string)];
    s3_file_manager.put_tags_on_file(file_name, tag_name_val_pairs);

    return Ok(
        Custom(Status::Ok, "Image Uploaded".to_string())
    );
}

配置CORS

路由寫好了以後,我就開始用curl或Postman來進行測試了,現在已經是時候開始把前端集成進來了。我需要適當設置響應頭以避免跨域問題。

Rocket依舊沒有支持這個特性。

然後我在GitHub代碼庫中找到了一些解決方案:

// CORS Solution below comes from: https://github.com/SergioBenitez/Rocket/issues/25
extern crate rocket;

use std::io::Cursor;
use rocket::fairing::{Fairing, Info, Kind};
use rocket::{Request, Response};
use rocket::http::{Header, ContentType, Method};

struct CORS();

impl Fairing for CORS {
    fn info(&self) -> Info {
        Info {
            name: "Add CORS headers to requests",
            kind: Kind::Response
        }
    }

    fn on_response(&self, request: &Request, response: &mut Response) {
        if request.method() == Method::Options || 
           response.content_type() == Some(ContentType::JSON) || 
           response.content_type() == Some(ContentType::Plain) {

            response.set_header(Header::new("Access-Control-Allow-Origin", "http://localhost:3000"));
            response.set_header(Header::new("Access-Control-Allow-Methods", "POST, GET, OPTIONS"));
            response.set_header(Header::new("Access-Control-Allow-Headers", "Content-Type"));
            response.set_header(Header::new("Access-Control-Allow-Credentials", "true"));
        }

        if request.method() == Method::Options {
            response.set_header(ContentType::Plain);
            response.set_sized_body(Cursor::new(""));
        }
    }
}

fn main() {
    
    rocket::ignite().attach(
        CORS()
    ).mount(
        "/", 
        routes![get_bucket_contents, upload_file]
    ).launch();
}

過了一會,我發現了rocket_cors,它幫助我大幅縮減了代碼量。

fn main() -> Result<(), Error> {
    let allowed_origins = AllowedOrigins::some_exact(&["http://localhost:3000"]);

    let cors = rocket_cors::CorsOptions {
        allowed_origins,
        allowed_methods: vec![Method::Get, Method::Post].into_iter().map(From::from).collect(),
        allowed_headers: AllowedHeaders::some(&["Content-Type", "Authorization", "Accept"]),
        allow_credentials: true,
        ..Default::default()
    }
    .to_cors()?;


    rocket::ignite().attach(cors)
                    .mount("/", routes![get_bucket_contents, upload_file])
                    .launch();

    Ok(())
}

運行起來

我們只需要一個簡單的cargo run命令就可以讓程序運行起來

在這裏插入圖片描述

我機器上的活動監視器告訴我這個程序正在運行中,並且只消耗了2.7MB內存。

而且這還只是沒有經過優化的調試版本。項目使用- release標籤打包的話,運行時只需要1.6MB內存。

在這裏插入圖片描述

基於Rust的後端服務器,我們請求/contents這個路由會得到如下響應:

{
    "data": [
        {
            "file_name": "Duck.gif",
            "presigned_url": "https://s3.amazonaws.com/rustic-images/Duck.gif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIARDWJNDW3U8329UDNJ%2F20200107%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200107T050353Z&X-Amz-Expires=1800&X-Amz-Signature=1369c003b2f54510882bf9982ab56d024d6c9d2655a4d86f8907313c7499b56d&X-Amz-SignedHeaders=host",
            "tags": "animal",
            "e_tag": "\"93c570cadd6b8b2f85b47c2f14fd82a1\"",
            "is_filtered": false
        },
        {
            "file_name": "GIZMO.png",
            "presigned_url": "https://s3.amazonaws.com/rustic-images/GIZMO.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIARDWJNDW3U8329UDNJ%2F20200107%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200107T050353Z&X-Amz-Expires=1800&X-Amz-Signature=040e76c2df5a9a54ed4fbc8490378cf732b32bae78f628448536fc610018c0c3&X-Amz-SignedHeaders=host",
            "tags": "robots",
            "e_tag": "\"2cde221a0c7a72c0a7a60cffce29a0bc\"",
            "is_filtered": false
        },
        {
            "file_name": "GreenSmile.gif",
            "presigned_url": "https://s3.amazonaws.com/rustic-images/GreenSmile.gif?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIARDWJNDW3U8329UDNJ%2F20200107%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20200107T050354Z&X-Amz-Expires=1800&X-Amz-Signature=d115b107de530ce15b3590abdbab355c2a9481a81131f88bf4ad2a59ca11bbac&X-Amz-SignedHeaders=host",
            "tags": "smile-face",
            "e_tag": "\"86854a599540f50bdc5e837d30ca34f9\"",
            "is_filtered": false
        }
    ]
}

前端的工作相對簡單一些,我們使用的是:

  • React
  • React Bootstrap
  • react-grid-gallery
  • react-tags-input

用戶可以在我們的頁面瀏覽圖片,也可以通過文件名或標籤來進行檢索或過濾。

在這裏插入圖片描述

用戶還可以通過拖拽來上傳文件,並且可以在提交上傳之前打上標籤。

在這裏插入圖片描述

我喜歡使用Rust構建應用程序的原因

  • Cargo對於依賴和應用管理的程度簡直令人驚歎
  • 編譯器對於我們處理編譯錯誤幫助非常大,有位博主在博客中描述了他是如何按照編譯器大指導來寫代碼的。我的經驗也比較類似。
  • 我需要的每一項功能都有crate,這讓我感到非常驚喜

在這裏插入圖片描述

  • 在線的Rust Playground,讓我可以運行小的代碼片段。
  • Rust語言服務器,已經很好的集成到了Visual Studio Code,它能夠提供實時錯誤檢查、格式設置、符號查找等。這讓我可以在幾個小時內不編譯就能取得不錯的進展。

不便、驚喜和麻煩

儘管Rust的文檔很棒,但我不得不依賴一些crates的文檔和例子。有些crates有很棒的集成測試,提供了一些關於如何使用的提示。當然了,Stack Overflow和Reddit也給我提供了很多幫助。

在這裏插入圖片描述

另外還要注意的是:

  • 理解所有權、生命週期和所有權借用會使學習難度陡增,特別是在爲期兩天的黑客馬拉松中努力提供功能時。我將它們與C++做比較並且弄清楚,但有時還是會感到困惑。
  • 在所有的事情中,Strings攔住了我幾分鐘,特別是String&str的區別更是令人困惑——直到我花了些時間來理解所有權、生命週期和所有權借用才搞清楚這些。

其他的一些觀察

  • Rust中沒有真正意義上的null類型,通常情況下,空值需要用Option類型的None來表示
  • 模式匹配非常棒,這是我在Scala中最喜歡的一個特性,在Rust中也一樣。這種代碼看起來表現力很強,並且允許編譯器標記未處理的情況。
match bucket_contents {
    Err(why) => match why {
        S3ObjectError::FileWithNoName => Err(Custom(
            Status::InternalServerError,
            "Encountered bucket objects with no name".into()
        )),
        S3ObjectError::MultipleTagsWithSameName => Err(Custom(
            Status::InternalServerError,
            "Encountered a file with a more than one tag named 'tags'".into()
        ))
    },
    Ok(s3_objects) => {
        let visible_s3_objects: Vec<S3Object> = s3_objects.into_iter()
                                                          .filter(|obj| !obj.is_hidden())
                                                          .collect();
        Ok(Json(BucketContents::new(visible_s3_objects)))
    }
}
  • 說起安全和不安全模式,你仍然可以進行更底層的編程,比如說在不安全的模式下可以和C語言代碼通過接口交互。儘管Rust中有很多正確性檢查,但你仍然可以在不安全模塊中做一些騷操作,例如解引用。讀代碼的人也可以從不安全模塊中獲取到很多信息。
  • 通過Box在堆中分配內存空間,而不是newdelete。剛開始感覺比較奇怪,但是也很容易理解。標準庫中還定義了其他的一些智能指針,如果你需要使用引用數量或者弱引用時就可以直接使用。
  • Rust中的異常也很有趣,因爲它沒有異常。你可以選擇使用Result<T, E>表示可以恢復的錯誤,也可以用panic!宏表示不可恢復的錯誤。
// This code:
// 1. Takes a vector of objects representing S3 contents
// 2. Uses filter to remove entries we don't care about
// 3. Uses map to transform each object into another type, but terminates iteration
// .  if the lambda passed to map returns an Err. 
// 4. If all iterations produced an Ok(S3Object) result, these are collected into a Vec<S3Object>
let bucket_contents: Result<Vec<S3Object>, S3ObjectError> = bucket_list
        .into_iter()
        .filter(|bucket_obj| bucket_obj.size.unwrap_or(0) != 0) // Eliminate folders
        .map(|bucket_obj| {
            if let None = bucket_obj.key {
                return Err(S3ObjectError::FileWithNoName);
            }

            let file_name = bucket_obj.key.unwrap();
            let e_tag = bucket_obj.e_tag.unwrap_or(String::new());
            let tag_req_output = s3_file_manager.get_tags_on_file(file_name.clone());
            let tags_with_categories: Vec<Tag> = tag_req_output.into_iter()
                                                            .filter(|tag| tag.key == "tags")
                                                            .collect();
            if tags_with_categories.len() > 1 {
                return Err(S3ObjectError::MultipleTagsWithSameName);
            }

            let tag_value = if tags_with_categories.len() == 0 {
                "".to_string()
            } else {
                tags_with_categories[0].value.clone()
            };

            let presigned_url = s3_file_manager.get_presigned_url_for_file(
                file_name.clone()
            );
            Ok(S3Object::new(
                file_name,
                e_tag,
                tag_value,
                presigned_url,
                false,
            ))
        })
        .collect();

手冊中是這樣描述的:

在多數情況下,Rust需要你儘可能瞭解錯誤,並且在編譯之前對其做出相應的處理。這個需求使你的程序更加健壯,保證你在發佈之前就可以發現並處理其中的錯誤。

要點和教訓

  • John Carmack曾經將編寫Rust的經歷描述爲“非常有益”。我同意這種感受,這次hackathon給我的感覺就像是打開了一扇新世界的大門並且發現了很多新鮮事物,這些收穫絕不僅僅是停留在代碼層面的。
  • 事後看來,我應該更加嚴謹的選擇網絡框架的。再多想一下的話,我可能會走出一條不同的道路。我下次可能會選擇ironactix-web, 或者是 tiny-http
  • 我只學到了Rust的皮毛,16個小時是不可能完全成爲一名Rustacean的,即使我對這門語言充滿了好奇心,也做了一些深入的瞭解。我對Rust的未來感到興奮,我認爲它爲構建應用程序帶來了很多規範,它是一種表現力非常豐富的語言,並且能爲我們提供與C++性能相當的運行速度和內存性能呢。

資源

RustIC後端代碼

RustIC前端代碼

Rusoto:一個Rust的AWS SDK

原文鏈接

https://medium.com/better-programming/learning-to-use-rust-over-a-16-hour-hackathon-5f0ac2f604df

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章