zerocal — 以 Rust 打造、運行於 shuttle.rs 的 Serverless 行事曆應用程式
每隔一段時間,我和朋友們會相約吃晚餐。我很珍惜這些夜晚,但最糟的部分就是安排這些聚會!
我們在群組裡發訊息。
等待回覆。
決定日期。
有人發出行事曆邀請。
事情才總算敲定。
除了晚餐本身,其他過程一點都不有趣。
身為講道理的人,你可能會想:「為什麼不直接用個排程應用程式就好?」。
我試過很多,沒有一個好用。全部都……太複雜了!
就讓我發個邀請,想來的人就來就好。
- 我不想為了你的行事曆/排程/什麼鬼應用程式去註冊帳號。
- 我不想還得把朋友一個個加進去。
- 我不想還得加朋友的朋友。
- 我不想還得加朋友的朋友的朋友。
- 你懂我的意思:我只想發個邀請,然後就算你不回覆也沒關係。
書呆子內向工程師的解法
💡 我們絕對需要的,就是再多一個行事曆應用程式,讓我們可以建立活動並用連結發送邀請!你大概沒想到會是這個吧?
還有,我不想用 Google Calendar 來建立活動,因為我 不 信任他們。
像任何講道理的人一樣,我想要一個能直接從終端機建立行事曆項目的方法。
上次我就是這樣跟朋友們提案的。他們的回應是:「不知道耶,聽起來像是為了找問題而想出的解法。」但你知道大家怎麼說的:別問海星怎麼指路。
與其空談,不如直接展示
那天晚上我回家做了一個網站,可以透過 GET 參數來建立行事曆項目。
它讓你可以直接在命令列輕鬆建立行事曆活動:
> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty
BEGIN:VCALENDAR
VERSION:2.0
PRODID:ICALENDAR-RS
CALSCALE:GREGORIAN
BEGIN:VEVENT
DTSTAMP:20221002T123149Z
CLASS:CONFIDENTIAL
DESCRIPTION:paaarty
DTEND:20221002T133149Z
DTSTART:20221002T123149Z
SUMMARY:Birthday
UID:c99dd4bb-5c35-4d61-9c46-7a471de0e7f4
END:VEVENT
END:VCALENDAR接著你可以把它存成檔案,再用你的行事曆應用程式開啟。
> curl https://zerocal.shuttleapp.rs?start=2022-11-04+20:00&duration=3h&title=Birthday&description=paaarty > birthday.ics
> open birthday.ics某種程度上,這算是個「Serverless(無伺服器)行事曆應用程式」,哈哈。伺服器上沒有任何狀態,它只是即時產生行事曆活動並回傳。
我是怎麼打造它的
你可能已經注意到網址裡有「shuttleapp.rs」。那是因為我用 shuttle.rs 來代管這個網站。
Shuttle 是一個針對 Rust 專案的代管服務,我一直很想試試看。
要使用超棒的 axum 網頁框架來初始化專案,我執行了
cargo install cargo-shuttle
cargo shuttle init --axum --name zerocal zerocal然後就看到開始所需的一切都已經準備好了:
use axum::{routing::get, Router};
use sync_wrapper::SyncWrapper;
async fn hello_world() -> &'static str {
"Hello, world!"
}
#[shuttle_service::main]
async fn axum() -> shuttle_service::ShuttleAxum {
let router = Router::new().route("/hello", get(hello_world));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}讓我們快速提交變更:
git add .gitignore Cargo.toml src/
git commit -m "Hello World"要部署程式碼,我需要先註冊一個 Shuttle 帳號。你可以在 https://www.shuttle.rs/login 完成註冊。
它會要求你授權存取你的 GitHub 帳號。
接著:
cargo shuttle login還有:
cargo shuttle deploy現在來看看 zerocal.shuttleapp.rs:
Hello World!部署第一個版本花不到 5 分鐘。真不錯!我們的自訂行事曆應用程式已經準備就緒。
撰寫應用程式
為了建立行事曆活動,我使用了 icalendar crate(感謝 hoodie 打造了這個好用的函式庫!)。iCalendar 是一種建立行事曆活動的標準,大多數行事曆應用程式都支援。
cargo add icalendar
cargo add chrono # For date and time parsing來建立一個示範用的行事曆活動:
let event = Event::new()
.summary("test event")
.description("here I have something really important to do")
.starts(Utc::now())
.ends(Utc::now() + Duration::days(1))
.done();夠簡單吧。
要怎麼回傳檔案!?
現在我們有了行事曆活動,需要把它回傳給使用者。但要怎麼以檔案的形式回傳呢?
在 axum 中有一個動態回傳檔案的範例,在這裡。
async fn calendar() -> impl IntoResponse {
let ical = Calendar::new()
.push(
// add an event
Event::new()
.summary("It works! 😀")
.description("Meeting with the Rust community")
.starts(Utc::now() + Duration::hours(1))
.ends(Utc::now() + Duration::hours(2))
.done(),
)
.done();
CalendarResponse(ical)
}這裡有幾個值得注意的地方:
- 每個行事曆檔案都是一組活動的集合,所以我們把活動包在代表集合的
Calendar物件裡。 impl IntoResponse是一個 trait,讓我們可以回傳任何實作它的型別。CalendarResponse是一個包在Calendar外層並實作IntoResponse的 newtype wrapper(新類型包裝)。
以下是 CalendarResponse 的實作:
/// Newtype wrapper around Calendar for `IntoResponse` impl
#[derive(Debug)]
pub struct CalendarResponse(pub Calendar);
impl IntoResponse for CalendarResponse {
fn into_response(self) -> Response {
let mut res = Response::new(boxed(Full::from(self.0.to_string())));
res.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/calendar"),
);
res
}
}我們只是建立一個新的 Response 物件,並將 Content-Type 標頭設為 iCalendar 檔案正確的 MIME 類型:text/calendar。然後回傳這個回應。
加入日期解析
這部分有點取巧,所以快速帶過也沒關係。我們需要從查詢字串中解析日期和持續時間。我使用了 dateparser,因為它支援超多不同的日期格式。
async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
let mut event = Event::new();
event.class(Class::Confidential);
if let Some(title) = params.get("title") {
event.summary(title);
} else {
event.summary(DEFAULT_EVENT_TITLE);
}
if let Some(description) = params.get("description") {
event.description(description);
} else {
event.description("Powered by zerocal.shuttleapp.rs");
}
if let Some(start) = params.get("start") {
let start = dateparser::parse(start).unwrap();
event.starts(start);
if let Some(duration) = params.get("duration") {
let duration = humantime::parse_duration(duration).unwrap();
let duration = chrono::Duration::from_std(duration).unwrap();
event.ends(start + duration);
}
}
if let Some(end) = params.get("end") {
let end = dateparser::parse(end).unwrap();
event.ends(end);
if let Some(duration) = params.get("duration") {
if params.get("start").is_none() {
let duration = humantime::parse_duration(duration).unwrap();
let duration = chrono::Duration::from_std(duration).unwrap();
event.starts(end - duration);
}
}
}
let ical = Calendar::new().push(event.done()).done();
CalendarResponse(ical)
}如果能支援更多像 now 和 tomorrow 這樣人類可讀的日期格式會更好,但就留到下次再說吧。
來測試看看:
> cargo shuttle run # This starts a local dev server
> curl 127.0.0.1:8000?start=2022-11-04+20:00&duration=3h&title=Birthday&description=Party
*🤖 bleep bloop, calendar file created*太好了,可以動!
在瀏覽器中開啟它,就會在行事曆中建立一個新活動:

還有,為了那些不用終端機來建立行事曆活動的怪人們,我們也在網站上加個表單吧。
加入表單
<form>
<table>
<tr>
<td>
<label for="title">Event Title</label>
</td>
<td>
<input type="text" id="title" name="title" value="Birthday" />
</td>
</tr>
<tr>
<td>
<label for="desc">Description</label>
</td>
<td>
<input type="text" id="desc" name="desc" value="Party" />
</td>
</tr>
<tr>
<td><label for="start">Start</label></td>
<td>
<input type="datetime-local" id="start" name="start" />
</td>
</tr>
<tr>
<td><label for="end">End</label></td>
<td>
<input type="datetime-local" id="end" name="end" />
</td>
</tr>
</table>
</form>我稍微修改了 calendar 函式,讓它在查詢字串為空時回傳表單:
async fn calendar(Query(params): Query<HashMap<String, String>>) -> impl IntoResponse {
// if query is empty, show form
if params.is_empty() {
return Response::builder()
.status(200)
.body(boxed(Full::from(include_str!("../static/index.html"))))
.unwrap();
}
// ...
}再經過一番調整,我們就得到一個充滿 Web 1.0 風格的可愛小表單:

就是這樣!我們現在有了一個可以建立行事曆活動的小型網頁應用程式。嗯,差不多了。我們還需要部署它。
部署
cargo shuttle deploy沒錯,就這樣。就是這麼簡單。感謝 shuttle.rs 的夥伴們讓這一切成為可能。
這個行事曆應用程式現在已經可以在 zerocal.shuttleapp.rs 上使用。
現在我終於可以傳個行事曆活動連結給朋友們,約下一次的酒吧夜遊。他們一定會很感激的。yeahyeah
用 100 行 Rust 從零到行事曆
能再次寫些純 HTML 感覺真好。
打造小應用程式永遠不嫌膩。
快去 GitHub 上看看原始碼,幫我把它變得更好吧!🙏
這裡有一些點子:
- ✅ 加入地點支援(例如
location=Berlin或location=https://zoom.us/test)。感謝 sigaloid。 - 支援更多人類可讀的日期格式(例如
now、tomorrow)。 - 支援重複發生的活動。
- 支援時區。
- 加入 Google Calendar 短連結(
https://calendar.google.com/calendar/render?action=TEMPLATE&dates=20221003T224500Z%2F20221003T224500Z&details=&location=&text=)。 - 加入從命令列建立行事曆活動的 Bash 範例指令。
- 縮短網址(例如
zerocal.shuttleapp.rs/2022-11-04T20:00/3h/Birthday/Party)?
快去看看 issue tracker(問題追蹤),也歡迎發 PR!
隨機一篇部落格