我用 Rust 重寫網站,性能居然提升了 18 倍!

爭做團隊核心程序員,關注「幽鬼」

摘要:對於構建中小型網站和個人博客來說,Hakyll 是一個不錯的靜態網站生成器庫,9 年前的 Jonas Hietala 正是選擇了 Hakyll 編寫博客網站。但隨着時間的推移,網站出現各種問題,考慮多種因素之後,Jonas Hietala 決定用 Rust 重寫,看看他是怎麼實現吧!

原文鏈接:https://www.jonashietala.se/blog/2022/08/29/rewriting_my_blog_in_rust_for_fun_and_profit/

作者 | Jonas Hietala

譯者 | 彎月

出品 | CSDN(ID:CSDNnews)

我使用 Hakyll 編寫我的靜態網站已經有 9 年了。在此之前,我使用過 Jekyll,還使用過 Perl 的 Mojolicious 和 PHP 的 Kohana 編寫更動態的頁面。

但這些已成爲過去,最近我決定使用 Rust 重新編寫我的網站。

需要解決的難題

我想通過這次重寫網站,重點解決以下難題:

  1. 速度越來越慢

在我的筆記本電腦上,重構一次網站需要 75 秒(不是編譯,只是生成網站)。我的網站只有 240 個帖子,所以我認爲不至於如此之慢。雖然我可以使用緩存系統,並在編輯期間通過 watch 命令查看更新後的帖子,但這個速度仍然是我無法接受的。

  1. 許多外部依賴項

雖然站點生成器本身是用 Haskell 編寫的,但除了許多 Haskell 庫之外,還有一些依賴項。我的博客助手腳本是用 Perl 編寫的,我使用 sassc 轉換 sass,然後利用 Python 的 pygments 高亮顯示語法,並使用 s3cmd 將生成的站點上傳到 S3。

安裝所有這些工具並保持最新狀態是一件很麻煩的事情。我希望能有一個統一的工具解決所有問題。

  1. 設置問題

有時,網站會出現一些問題,我必須花時間調試和修復。每當我有一些新的寫作思路,卻發現網站生成器有問題,就會覺得很沮喪。

你可能會想,什麼地方會出問題?

有時,一些軟件包的更新可能會破壞網站。例如,

[ERROR] Prelude.read: no parse

(只有我的臺式機會遇到這個錯誤,在我的筆記本電腦上運行良好。)

Magic.c: loadable library and perl binaries are mismatched (got handshake key 0xcd00080, needed 0xeb00080)

‍(只有我的筆記本電腦會遇到這個錯誤,這我的臺式機上運行良好。)

我知道上述問題都可以得到解決,但我只想要一些可以正常工作的東西。

4.Haskell 的精神開銷

我很喜歡 Haskell,尤其是純函數的部分,而且我非常喜歡 Hakyll 通過聲明式的方法處理站點配置。以生成靜態頁面(即單獨的頁面)爲例:

match "static/*.markdown" $ do
route   staticRoute
compile $ pandocCompiler streams
>>= loadAndApplyTemplate "templates/static.html" siteCtx
>>= loadAndApplyTemplate "templates/site.html" siteCtx
>>= deIndexUrls

即使你不理解 $ 和 >>=,也可以看懂我們想從 static / 文件夾中查找文件,然後將它們發送到 pandocCompiler(以轉換 markdown 代碼)和模板,並刪除 URL 中的 index(避免鏈接以 index.html 結尾)。

簡單明瞭!

但是我已經很多年沒有使用過 Haskell 了,而且我不想在網站上添加複雜的東西。

例如,我曾想在帖子中添加鏈接 “下一個 / 上一個”,但後來我不得不花時間重新學習 Haskell 和 Hakyll。即便如此,最後我想到的解決方案還是超級慢,因爲我採用了一個線性搜索來查找下一個 / 上一個帖子,但我未能搞清楚如何正確地利用 Hakyll 實現這個鏈接。

我相信你有更好的方法,但對我來說,這樣的小功能不值得付出這麼大的努力。

爲什麼選擇 Rust?

  1. 我很喜歡 Rust,而且它很適合業餘項目

2.Rust 非常高效,非常擅長轉換文本。

3.Cargo 很流行,只要安裝了 Rust,運行 cargo build,就可以構建網站

爲什麼要重新發明輪子?

我想編寫一個靜態站點生成器,這是一個非常有趣的項目,難度應該不大,但可以讓我全權控制網站,而且靈活性完勝我使用過的所有網站生成器。

實現細節

我不打算在此介紹所有的細節,如果你感興趣,請查看源代碼(地址:https://github.com/treeman/jonashietala)。

利用現有的庫處理難度較大的工作

起初我很擔心重新實現我喜歡的 Hakyll 功能的難度會太大,例如模板引擎、多種語言的語法高亮顯示,以及通過 watch 命令自動重新生成編輯過的頁面並充當文件服務器,這樣我就可以在寫作的過程中隨時在瀏覽器中查看帖子。

事實證明,我可以利用現有的庫處理一些難度較大的工作。以下是我使用的一些效果很好的庫:

它比 Hakyll 更強大,甚至可以執行循環之類的操作:

<div>
<nav>
Posted in {% for tag in tags %}{% if loop.index0 > 0 %}, {% endif %}<a href="{{ tag.href }}">{{ tag.name }}</a>{% endfor %}.
</nav>
</div>

這個庫非常適合 CommonMark(Markdown 標準的語法規範)。

雖然速度很快,但支持的功能不如 Pandoc 多,所以我不得不做一些擴展。

即便使用了這些庫,Rust 源代碼本身也超過了 6000 行。在有些情況下,Rust 代碼可能會很冗長,我的代碼確實不夠漂亮,但是最後的代碼量仍然超過了預期。

Markdown 轉換

如果我的帖子都採用標準的 markdown,這個轉換過程會更加容易,但多年來我添加了很多 pulldown-cmark 不支持的功能和擴展。所以,我不得不自己編寫代碼。

預處理

我有一個預處理步驟,利用多個圖像來生成插圖。這是一個通用的處理步驟,形式如下:

::: <type>
<content>
:::

我使用這個預處理來生成各種圖像集合,例如 Flex、Figure 和 Gallery。下面是一個例子:

::: Flex
/images/img1.png
/images/img2.png
/images/img3.png
Figcaption goes here
:::

需要轉換成:

<figure class="flex-33">
<img src="/images/img1.png" />
<img src="/images/img2.png" />
<img src="/images/img3.png" />
<figcaption>Figcaption goes here</figcaption>
</figure>

那麼,我該如何實現呢?當然是使用正則表達式。

use lazy_static::lazy_static;
use regex::{Captures, Regex};
use std::borrow::Cow;
lazy_static! {
static ref BLOCK: Regex = Regex::new(
r#"(?xsm)
^
# Opening :::
:{3}
\s+
# Parsing id type
(?P<id>\w+)
\s*
$
# Content inside
(?P<content>.+?)
# Ending :::
^:::$
"#
)
.unwrap();
}
pub fn parse_fenced_blocks(s: &str) -> Cow<str> {
BLOCK.replace_all(s, |caps: &Captures| -> String {
parse_block(
caps.name("id").unwrap().as_str(),
caps.name("content").unwrap().as_str(),
)
})
}
fn parse_block(id: &str, content: &str) -> String {
...
}

(實際的圖像解析會更加繁瑣,不在此贅述。)

擴展 pulldown-cmark

此外,我還擴展了 pulldown-cmark:

// Issue a warning during the build process if any markdown link is broken.
let transformed = Parser::new_with_broken_link_callback(s, Options::all(), Some(&mut cb));
// Demote headers (eg h1 -> h2), give them an "id" and an "a" tag.
let transformed = TransformHeaders::new(transformed);
// Convert standalone images to figures.
let transformed = AutoFigures::new(transformed);
// Embed raw youtube links using iframes.
let transformed = EmbedYoutube::new(transformed);
// Syntax highlighting.
let transformed = CodeBlockSyntaxHighlight::new(transformed);
let transformed = InlineCodeSyntaxHighlight::new(transformed);
// Parse `{ :attr }` attributes for blockquotes, to generate asides for instance.
let transformed = QuoteAttrs::new(transformed);
// parse `{ .class }` attributes for tables, to allow styling for tables.
let transformed = TableAttrs::new(transformed);

‍以前我喜歡降低標題等級,並嵌入原始 YouTube 鏈接,而且實現起來很簡單(不過,在處理前後的步驟中嵌入 YouTube 鏈接可能會更好)。

Pandoc 支持向任意元素添加屬性和類,例如:

![](/images/img1.png){ height=100 }

可以轉換成:

<figure>
<img src="/images/img1.png" height="100">
</figure>

這種用法在我的代碼中比比皆是,所以我決定重新實現。

我在 Pandoc 中使用的功能還有一個不受支持:在 HTML 標籤內計算 markdown。比如下面這段代碼就無法正確呈現:

<aside>
My [link][link_ref]
</aside>

起初我的計劃是,在通用預處理中實現,但後來發現這樣做會丟失鏈接引用。比如下面這個例子:

::: Aside
My [link][link_ref]
:::
[link_ref]: /some/path

link 不會變成鏈接,因爲我們只會在 ::: 內解析。

> Some text
{ :notice }

這段代碼會調用 notice 解析器,在這個示例中,它將創建一個 標記(而不是 標記),同時保留已解析的 markdown。

雖然現有的 crate 使用 syntect 高亮顯示代碼,但我自己編寫了一個 crate,可以將它包裝在 標記中,並支持內聯代碼高亮顯示。例如:

Inside row: `let x = 2;`rust

可以轉換成:

Inside row: let x = 2;

性能提升

我沒有在提高性能上花費太多時間,但以下兩方面的改動對性能產生了很大影響:

第一,如果使用 syntect,並且使用自定義的語法,則應該將 SyntaxSet 壓縮爲二進制格式。

第二,使用 rayon 並行化渲染。渲染是解析 Markdown、應用模板以及創建輸出文件的過程。rayon 非常適合該任務,因爲這項任務的瓶頸是 CPU,而且 rayon 非常易於使用(如果代碼結構正確)。例如,下面是一個簡化的渲染:

fn render(&self) -> Result<(){
let mut items = Vec::new();
// Add posts, archives, and all other files that should be generated here.
for post in &self.content.posts {
items.push(post.as_ref());
}
// Render all items.
items
.iter()
.try_for_each(|item| self.render_item(*item))
}

如果想並行化上述處理,我們只需要將 iter() 改爲 par_iter():

use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
items
.par_iter() // This line
.try_for_each(|item| self.render_item(*item))

只需要修改這一個地方!

誠然,性能上的提升非常小,幾處巨大的性能提升都來自我使用的庫。例如,我的舊網站使用了一個用 Python 編寫的外部 pygments 來高亮顯示語法,而現在我使用了 Rust 的高亮顯示,它的速度要快得多,並且很容易並行化。

完整性檢查

以前,我的網站給我的最大困擾是,太容易出錯了。例如,鏈接到不存在的頁面或圖像,忘記定義鏈接引用,或者忘記在發佈之前更新鏈接。

因此,除了測試 watch 命令之類的基本功能之外,我還需要解析整個站點,並檢查所有內部鏈接是否存在以及是否正確。此外,我還需要手動檢查外部鏈接。

最終的結果

我在本文開頭提到了一些難題,下面我們來看看我是否解決了這些難題。

  1. 性能

如今在我的筆記本電腦上,重建完整的站點需要 4 秒(不包括編譯時間)。18 倍的性能提升看起來不錯嘛。我相信,網站的性能還有進一步提升的空間,例如使用 rayon 處理文件 I/O,使用異步處理,而且我沒有緩存系統,所以每次構建都會重新生成所有文件。

請注意,這並不是說 Rust 的速度遠超 Haskell,這不過是兩種實現的比較。我相信有人能夠使用 Haskell 編寫出更快的實現。

  1. 單一依賴

現在網站的一切都是用 Rust 編寫的,不需要安裝外部腳本或工具。

  1. Cargo 正常工作

只要系統安裝了 Rust,cargo build 就可以正常工作。我認爲這是 Rust 最大的優勢之一:構建系統可以正常工作。

你不必手動尋找丟失的依賴項,爲了實現跨平臺而苦惱,或者在構建系統自動拉取更新時破壞一切。你只需要靜靜等待代碼編譯完成。

  1. Rust 減輕了我的負擔

雖然我找到了更簡單的方法來實現上一個 / 下一個鏈接,但我並不認爲這意味着 Rust 比 Haskell 更簡單或更容易,這只是意味着 Rust 對我個人來說更容易理解。

最大的原因還是要歸結爲實踐。最近我一直在使用 Rust,但對於 Haskell,我只在大約十年前構建這個網站時學習過一段時間,之後再也沒有接觸過。

我敢肯定,如果停止使用 Rust,過個十年再次接觸,我也會遇到很多困難。

總的來說,我很滿意這次重寫網站的結果。這是一個有趣的項目,儘管工作量超出了我的預期,但的確解決了我的一些煩惱。


歡迎關注「幽鬼」,像她一樣做團隊的核心。

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