從使用 Rust 構建 SaaS 平臺中學到的東西

這篇文章分享 Meteroid 團隊使用 Rust 構建 SaaS 項目中學到的東西。

Meteroid 是以產品爲主導的雲原生定價和計費的 SaaS 平臺,解決了傳統計費系統的複雜性和侷限性,簡化了複雜計費模型的創建、擴展和維護,自動生成發票,併爲實現 kpi 提供清晰、可操作的見解。它消除了客戶使用和計費之間的差距,確保了準確性和透明度。

這篇文章旨在提供他們經驗的高層次概述。

爲什麼用 Rust?

爲項目選擇合適的語言從來沒有一個放之四海而皆準的決定。關於 Meteroid 項目的背景:

因此,有一些不可協商的需求,它們恰好非常適合 Rust:性能、安全性和併發性。Rust 實際上消除了所有與內存管理相關的 bug,它的併發原語非常吸引人 (並且沒有讓人失望)。

在 SaaS 中,當處理敏感或關鍵任務時,所有這些特性都特別有價值。它顯著減少了內存使用,這也是構建可擴展和可持續平臺的主要好處。

經驗 1:學習曲線是真實存在的

學習 Rust 並不像學習另一門語言。所有權、借用和生命週期等概念最初可能會讓人望而生畏,這使得原本微不足道的代碼非常耗時。

儘管這個生態系統是令人愉快的 (稍後會詳細介紹),但有時不可避免地需要編寫較低級別的代碼。

例如,考慮一個相當基礎的 API 中間件 (Tonic/Tower),它只是簡單地報告計算持續時間:

impl<S, ReqBody, ResBody> Service<Request<ReqBody>> for MetricService<S>
where
    S: Service<Request<ReqBody>, Response = Response<ResBody>, Error = BoxError>
        + Clone + Send + 'static,
    S::Future: Send + 'static,
    ReqBody: Send,
{
    type Response = S::Response;
    type Error = BoxError;
    type Future = ResponseFuture<S::Future>;

    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
        self.inner.poll_ready(cx)
    }

    fn call(&mut self, request: Request<ReqBody>) -> Self::Future {
        let clone = self.inner.clone();
        let mut inner = std::mem::replace(&mut self.inner, clone);
        let started_at = std::time::Instant::now();
        let sm = GrpcServiceMethod::extract(request.uri());

        let future = inner.call(request);

        ResponseFuture {
            future,
            started_at,
            sm,
        }
    }
}

#[pin_project]
pub struct ResponseFuture<F> {
    #[pin]
    future: F,
    started_at: Instant,
    sm: GrpcServiceMethod,
}

impl<F, ResBody> Future for ResponseFuture<F>
where
    F: Future<Output = Result<Response<ResBody>, BoxError>>,
{
    type Output = Result<Response<ResBody>, BoxError>;

    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
        let this = self.project();
        let res = ready!(this.future.poll(cx));
        let finished_at = Instant::now();
        let delta = finished_at.duration_since(*this.started_at).as_millis();
        // this is the actual logic
        let (res, grpc_status_code) = (...) 

        crate::metric::record_call(
            GrpcKind::SERVER,
            this.sm.clone(),
            grpc_status_code,
            delta as u64,
        );

        Poll::Ready(res)
    }
}

除了泛型類型、泛型生命週期和 trait 約束之外,還需要爲簡單的服務中間件編寫自定義的 Future 實現。

學習曲線可能因背景而異,如果習慣了 JVM 處理繁重的工作,並且像他們一樣使用更成熟、更廣泛的生態系統,那麼可能需要更多的努力來理解 Rust 的獨特概念和範式。

然而,一旦掌握了這些概念和原語,它們就會成爲令人難以置信的強大工具,即使偶爾需要編寫一些樣板文件或宏,也能提高工作效率。

值得一提的是,谷歌已經在相當短的時間內成功地將團隊從 Go 和 C++ 過渡到 Rust,並取得了積極的成果。

爲了使學習曲線更平滑,可以考慮以下幾點:

經驗 2:生態系統仍在成熟

Rust 中的底層生態系統確實令人難以置信,擁有被社區廣泛採用的精心設計和維護的庫。這些庫爲構建高性能和可靠的系統奠定了堅實的基礎。

但是,隨着技術棧的增加,事情可能會變得稍微複雜一些。

例如,在數據庫生態系統中,雖然存在用於關係數據庫的 sqlx 和 diesel 等優秀 crate,但對於許多異步或非關係數據庫客戶端,情況就更加複雜了。這些領域的高質量庫,即使被大公司使用,通常也只有一個維護者,從而導致開發速度變慢和潛在的維護風險。

對於分佈式系統原語,挑戰更加明顯,可能需要實現自己的解決方案。

經驗 3:文檔存在於代碼中

許多庫都有非常完善的方法文檔,並在代碼註釋中提供了全面的示例。如果有疑問,請深入研究源代碼並進行探索。通常會找到所尋求的答案,並對庫的內部工作有更深入的瞭解。

雖然帶有使用指南的外部文檔仍然很重要,可以節省開發人員的時間和挫敗感,但在 Rust 生態系統中,必要時準備好深入研究代碼是至關重要的。

比如 docs.rs 網站爲公共 Rust crate 提供了訪問基於代碼的文檔的便捷方式。或者,可以使用 cargo doc 在本地爲所有依賴項生成文檔。

不用說,另一個有用的技巧是尋找示例 (大多數庫都有一個 / examples 文件夾) 和其他使用這個庫的項目,並參與這些社區。它們總是爲如何使用庫提供有價值的指導,並且可以作爲自己項目實現的起點。

經驗 4:不要追求完美

當開始使用 Rust 時,人們總是希望儘可能地寫出最習慣的、性能最好的代碼。然而,大多數情況下,以簡單和效率爲優先。

例如,使用 clone() 或 Arc 在線程之間共享數據可能不是最節省內存的方法,但它可以極大地簡化代碼並提高可讀性。只要意識到性能是否是主要的影響,並做出明智的決策,優先考慮簡單性是完全可以接受的。

記住,過早優化是萬惡之源。首先專注於編寫乾淨、可維護的代碼,然後在必要時進行優化。不要嘗試微優化 (除非真的需要)。Rust 的強類型系統和所有權模型已經爲編寫高效和安全的代碼提供了堅實的基礎。

當需要優化性能時,請關注關鍵路徑,並使用 perf 和 flamgraph 等分析工具來識別代碼中真正的性能熱點。對於工具和技術的全面概述,推薦《Rust Performance Book》。

經驗 5:錯誤也可以是好事

Rust 的錯誤處理非常優雅,使用 Result 類型和? 操作符鼓勵顯式錯誤處理和傳播。然而,這不僅僅是處理錯誤,它還涉及提供具有可跟蹤的清晰和信息豐富的錯誤消息。

Meteroid 團隊使用了 thiserror 庫,它簡化了使用錯誤消息創建自定義錯誤類型的過程。

在大多數 Rust 用例中,不太關心底層錯誤類型的棧跟蹤信息,而更喜歡將其直接映射到域內的錯誤信息類型。

#[derive(Debug, Error)]
pub enum WebhookError {
    #[error("error comparing signatures")]
    SignatureComparisonFailed,
    #[error("error parsing timestamp")]
    BadHeader(#[from] ParseIntError),
    #[error("error comparing timestamps - over tolerance.")]
    BadTimestamp(i64),
    #[error("error parsing event object")]
    ParseFailed(#[from] serde_json::Error),
    #[error("error communicating with client : {0}")]
    ClientError(String),
}

花時間製作乾淨且信息豐富的錯誤消息可以極大地增強開發人員的體驗並簡化調試。這是一個小的努力,但產生顯著的長期效益。

然而,有時保留完整的錯誤鏈是很有意義的,並需要在此過程中添加額外的上下文信息。

Meteroid 團隊目前正在試驗 error-stack,這是一個由 hash.dev 維護的庫,它可以附加額外的上下文並將其保存在整個錯誤樹中。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/YHuNefORYrIIDQ3-MRJpcg