Viết ứng dụng đọc tin HackerNews bằng Rust
Dạo này mình toàn viết bài linh tinh, lâu rồi chưa thấy viết bài kĩ thuật nào mới nên hôm nay mình viết trở lại, mất công các bạn lại bảo mình không biết code =)))
Chủ đề lần này sẽ là: Viết ứng dụng đọc tin trên HackerNews, và chúng ta sẽ sử dụng Rust.
Qua bài này, các bạn sẽ được làm quen với rất nhiều kĩ thuật trong Rust như:
- Làm web với framework rocket.rs
- Tạo và sử dụng module
- Parse nội dung RSS dùng crate rss
- Chuyển đổi giữa các cấu trúc dữ liệu khác nhau
- Kiểm soát lỗi với kiểu dữ liệu
Result
- Viết và chạy Unit Test với
cargo
Giới thiệu vậy đủ rồi, giờ vô nội dung chính.
Cụ thể là làm cái gì đây?
Chúng ta sẽ viết một ứng dụng đọc tin RSS từ feed của HackerNews tại địa chỉ https://news.ycombinator.com/rss. Việc đọc và parse nội dung RSS chúng ta sẽ sử dụng crate có tên là rss.
Sau đó, chúng ta sẽ viết một giao diện web đơn giản để hiển thị danh sách các mẫu tin đã parse được. Chúng ta sẽ sử dụng framework có tên là rocket.rs cho phần này.
Lý do vì sao à? Tại vì HackerNews, như các bạn đã biết, thì giao diện của nó quá xấu. Tự nhận thấy mình có thể làm xấu hơn nên mình quyết định làm thôi.
Rồi, giờ code luôn được chưa?
Được luôn. Lưu ý, nếu bạn chưa biết Rust là gì và chưa nắm được cách cài đặt, thì có thể tham khảo các bài viết sau trước khi chúng ta bắt đầu:
Zeroend: Khởi tạo project
Vâng, mọi dự án luôn bắt đầu bằng cái bước setup project, không trật đi đâu được:
$ cargo new --bin hackernews-rs
Lệnh trên sử dụng cargo
để tạo một binary project (nôm na là project chạy được - executable) có tên là hackernews-rs
.
Backend: Thu thập nội dung từ RSS
Chúng ta đi vào phần khó trước, phần dễ để dành tráng miệng lúc sau. Ông bà ta vẫn có câu vạn sự khởi đầu nan mà.
Nhưng mà đâu đó giữa trong giang hồ cũng tồn tại câu nói gian nan bắt đầu nản...
Mà mình thì không muốn các bạn nản, vì như vậy các bạn sẽ không đọc bài mình viết nữa :)) cho nên ở phần này, thay vì tự viết bộ parser, chúng ta sẽ sử dụng một crate có sẵn, là rss
.
rss
Cài đặt crate Để cài đặt crate này, chúng ta mở file Cargo.toml
của project, tại đây bạn sẽ thấy phần khai báo [dependencies]
đang bỏ trống. Add thêm một dòng vào ngay bên dưới, như sau:
Cargo.toml
[dependencies]
rss = { version = "*", features = ["from_url"] }
Như vậy chúng ta đã cho cargo
biết rằng chúng ta sẽ add một crate tên là rss
với phiên bản mới nhất (ký hiệu dấu *
) vào project. Phần features
là tùy chọn để sử dụng thêm chức năng from_url
của crate này. Đối với các loại crate khác thì bạn không nhất thiết phải có phần này.
Trong thực tế, bạn nên chỉ định phiên bản cụ thể của crate khi muốn cài, để tránh việc các crate update thường xuyên kéo theo nhiều thay đổi, và bạn sẽ gặp bug phát sinh.
Trong file main.rs
, chúng ta dùng từ khóa extern crate
để import:
main.rs
extern crate rss;
fn main() {
// Make it happen, or it will never happen.
}
Tạo module đọc tin
Chúng ta sẽ tạo ra một module tên là fetch, làm nhiệm vụ đọc và parse file RSS từ bên ngoài, trả về một mảng (hoặc một vector), có các phần tử là từng bản tin.
Đầu tiên, tạo một file mới trong thư mục src
(cùng thư mục với main.rs
), đặt tên là fetch.rs
.
$ cd hackernews-rs
$ touch src/fetch.rs
Và gõ vào đoạn code sau:
fetch.rs
use super::rss;
use rss::{Channel, Item};
pub type FetchResult<T> = Result<T, rss::Error>;
pub fn fetch_from(url: &str) -> FetchResult<Vec<Item>> {
Ok(Channel::from_url(url)?.items().to_vec())
}
Và khai báo mod
ở trong main.rs
:
main.rs
extern crate rss;
mod fetch;
use fetch::*;
fn main() {
...
Trong đoạn code trên, chúng ta sử dụng hàm rss::Channel::from_url
của crate rss
, hàm này có chức năng download gói tin RSS từ một URL bên ngoài, parse nó và trả về một đối tượng kiểu rss::Channel
.
Mục đích của chúng ta là lấy ra danh sách các mẫu tin. Trong một đối tượng kiểu rss::Channel
, chúng ta có hàm items()
để lấy các mẫu tin, hàm này trả về một slice
(có thể hiểu là mảng), và chúng ta cần chuyển nó về dạng vector với hàm to_vec()
.
Kiểu FetchResult<T>
ở đây là cách khai báo nhằm rút gọn cho cú pháp Result<T, E>
.
Một đối tượng kiểu Result<T, E>
sẽ trả về một trong 2 giá trị tùy vào từng trường hợp:
- Giá trị kiểu
T
: Trong trường hợp lệnh thực hiện thành công - Giá trị lỗi kiểu
E
: Trong trường hợp xảy ra lỗi
Trong trường hợp này, T
là một Vec<Item>
như đã nói ở trên, E
là rss::Error
, là kiểu báo lỗi từ phía crate rss
.
Bạn đã viết xong module fetch, giờ làm sao biết được nó có chạy hay không? Phải test!
Test kiểu chày cối
Với nhiều người, họ sẽ đơn giản là vào trong main.rs
và gõ đoạn code kiểu như thế này:
main.rs
extern crate rss;
mod fetch;
use fetch::*;
fn main() {
let result = fetch_from("https://notes.huy.rocks/rss.xml");
if result.is_ok() {
println!("Yay! It's worked!");
}
}
Chạy thử thấy màn hình in ra dòng chữ:
Yay! It's worked!
Vậy là yên tâm nó chạy được.
Nếu bạn định test như cách trên thì xin đừng! Đây không phải là Unit Test, mà đây cũng không phải là cách để test, vì giờ nó chạy được, ai biết được ít hôm nữa bạn sửa code thì nó có còn chạy được không? Vì hàm main
đâu phải là nơi để giữ đoạn code test này của bạn mãi mãi? Đúng hêm?
Viết test cases
Nếu bạn chưa biết viết test trong Rust như thế nào, có thể tham khảo bài Cách viết test trong Rust the idiomatic way.
Đối với hàm fetch_from
ở trên, chúng ta sẽ có tầm 3 đến 4 test cases, cụ thể là:
- Fetch từ một link RSS hợp lệ có trả về kết quả hợp lệ hay không? Để test case này, ta gọi hàm
fetch_from
với tham số là một liên kết RSS hợp lệ. - Fetch từ một link không tồn tại có trả về thông báo lỗi hay không? Để test case này, ta truyền vào một URL không caa thật.
- Fetch từ một link không phải là RSS thì có trả về thông báo lỗi hay không? Để test case này, ta truyền vào một URL chứa nội dung không phải RSS, ví dụ như là một nội dung JSON.
Ở cuối file fetch.rs
, ta thêm vào 3 hàm test từ 3 case ở trên:
fetch.rs
...
#[test]
fn test_fetch_from_valid_url() {
let result = fetch_from("https://notes.huy.rocks/rss.xml");
assert!(result.is_ok());
assert!(result.unwrap().len() > 0);
}
#[test]
fn test_fetch_from_invalid_url() {
let result = fetch_from("https://where-superman-meet-wonderwoman.com/and-they-got-married.xml");
assert!(result.is_err());
}
#[test]
fn test_fetch_from_invalid_rss_url() {
let result = fetch_from("https://xkcd.com/info.0.json");
assert!(result.is_err());
}
Chạy test với lệnh:
cargo test
Kết quả sẽ như thế này, cả 3 test case đều pass hết, chứng tỏ người code rất xịn:
running 3 tests
test fetch::test_fetch_from_invalid_url ... ok
test fetch::test_fetch_from_invalid_rss_url ... ok
test fetch::test_fetch_from_valid_url ... ok
test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Giờ thì chúng ta có thể đảm bảo module fetch hoạt động chính xác rồi.
Chuyển danh sách bản tin thành JSON
Để lấy danh sách các tin mới nhất từ HackerNews, chúng ta gọi hàm fetch_from
với tham số là bản tin RSS của trang này:
let result = fetch_from("https://news.ycombinator.com/rss");
Việc tiếp theo, chúng ta sẽ viết phần frontend để hiển thị nội dung này ra trên nền web. Nhưng trước tiên, ta cần chuyển danh sách bản tin dạng Vec<Item>
này thành nội dung định dạng JSON.
Việc chuyển đổi, chúng ta sẽ dùng một crate tên là serde
. Để cài crate này, mở file Cargo.toml
và thêm vào phần [dependencies]
:
Cargo.toml
...
serde = "1.0.11"
serde_derive = "1.0.11"
serde_json = "1.0.2"
Trong main.rs
, dùng từ khóa extern crate
để import các package của serde
vào:
extern crate serde;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate serde_json;
#[macro_use]
ở đây gọi là attribute
, và attribute này cho cargo
biết chúng ta muốn sử dụng các macro
được khai báo bên trong crate này.
Kiểu rss::Item chứa rất nhiều trường mà chúng ta không cần dùng tới, trên thực tế, chúng ta chỉ cần các thông tin như title
, link
, description
của một Item
, vì thế, chúng ta nên tạo một kiểu dữ liệu khác để chức các thông tin cần trích xuất.
Cụ thể ở đây ta tạo một struct mới tên là RSSItem
. Thêm đoạn code sau vào fetch.rs
:
fetch.rs
#[derive(Serialize)]
pub struct RSSItem {
pub title: String,
pub link: String,
pub description: String,
pub pub_date: String,
}
impl From<Item> for RSSItem {
fn from(item: Item) -> Self {
RSSItem {
title: item.title().unwrap_or_default().to_owned(),
link: item.link().unwrap_or_default().to_owned(),
description: item.description().unwrap_or_default().to_owned(),
pub_date: item.pub_date().unwrap_or_default().to_owned(),
}
}
}
Trong đoạn code trên, ta tạo ra một struct tên là RSSItem
, kế thừa thuộc tính Serialize
của serde
, để thư viện này dễ dàng chuyển đổi (convert) nó về định dạng JSON sau này.
Từ khóa impl
dùng để implement một trait
cho một kiểu dữ liệu bất kỳ, ở đây là chúng ta implement trait có tên là From<T>
cho RSSItem
.
Trait From<T>
thực hiện việc ép kiểu (casting) từ kiểu dữ liệu T
sang kiểu dữ liệu được implement, mà ở đây chính là RSSItem
.
Tiếp theo, chúng ta thay đổi hàm fetch_from
một tí, để trả về một vector kiểu RSSItem
thay vì rss::Item
:
fetch.rs
pub fn fetch_from(url: &str) -> FetchResult<Vec<RSSItem>> {
Ok(Channel::from_url(url)?
.items()
.into_iter()
.map(|item| RSSItem::from(item.clone()))
.collect())
}
Ở đây chúng ta dùng hàm into_iter()
để chuyển vector rss::Item
về dạng có thể duyệt qua được. Sau đó dùng hàm map()
để xử lý chuyển đổi từng phần tử kiểu rss::Item
sang kiểu RSSItem
, nhờ vào trait From<T>
mà chúng ta đã implement ở trên.
Và cuối cùng, để đảm bảo rằng sau khi thay đổi, hàm fetch_from
vẫn hoạt động bình thường, và cho kết quả đúng, ta thêm vào một test case mới:
fetch.rs
...
#[test]
fn test_fetch_is_convertable_to_json() {
let items = fetch_from("https://notes.huy.rocks/rss.xml");
assert!(items.is_ok());
let json_data = json!({ "items": items.unwrap() });
assert!(json_data["items"].is_array());
assert!(json_data["items"][0].is_object());
assert!(json_data["items"][0]["title"].is_string());
}
Ở đây chúng ta sử dụng macro json!()
của crate serde_json
để chuyển một đối tượng thành một nội dung kiểu serde_json::value::Value
. Các bạn thử đọc document của đối tượng này và tự suy luận xem vì sao lại viết hàm test ở trên như thế nhé.
Giờ chạy test thôi:
running 4 tests
test fetch::test_fetch_invalid_url ... ok
test fetch::test_fetch_invalid_rss_feed ... ok
test fetch::test_fetch_is_convertable_to_json ... ok
test fetch::test_fetch_valid_rss_url ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out
Như vậy có nghĩa là hàm fetch_from
vẫn hoạt động tốt sau khi bị thay đổi, và đến đây thì có thể yên tâm bước qua giai đoạn tiếp theo được rồi.
Frontend: Hiển thị bảng tin
Nào, bây giờ chúng ta bắt tay vào phần frontend thôi.
Chúng ta sẽ sử dụng một framework có tên là rocker.rs, đây là một framework gọn nhẹ, với chức năng chính là routing, tuy nhiên có thể mở rộng ra với "rất nhiều chức năng phụ thêm, mà nếu không cần dùng thì bạn có thể không cần cài", đoạn in nghiên cũng là nguyên văn lời dụ dỗ của anh Sergio, tác giả Rocket.rs, nói với mình khi mình lên IRC hỏi tìm một web framework gọn nhẹ cho Rust.
Cài đặt rocket.rs
Để cài đặt rocket.rs
, chúng ta cài như khi cài một crate mới ở các bước trên.
Cargo.toml
[dependencies]
...
rocket = "0.3.0"
rocket_codegen = "0.3.0"
[dependencies.rocket_contrib]
default-features = false
features = ["handlebars_templates"]
Các crate cần cài cho framework này gồm có: rocket
, rocket_codegen
và rocket_contrib
. Riêng gói rocket_contrib
chúng ta chỉ dùng chức năng handlebars_templates
.
Làm quen với rocket.rs
Tiếp theo, mở file main.rs
và gõ vào đoạn code như sau:
main.rs
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rocket;
extern crate rocket_contrib;
#[get("/")]
fn index() -> &'static str {
"Wheresoever you go, go with all your heart."
}
fn main() {
rocket::ignite()
.mount("/", routes![index])
.launch();
}
Đoạn code trên tạo ra một web server đơn giản, có 1 route là /
, được thiết lập bằng thuộc tính #[get("/")]
, chính là trang chủ, và được handle bằng hàm index()
, hàm này trả về một chuỗi &str
.
'static
đặt trong dòng khai báo &'static str
được gọi là lifetime của một biến. Biểu diễn phạm vi tồn tại của một biến đó trong hàm. Trong trường hợp này biến đó mang kiểu &str
.
Trong Rust, 'static
là lifetime dài nhất, và tồn tại cho đến khi chương trình kết thúc.
Bây giờ chúngta thử chạy server này bằng lệnh:
cargo run
Output hiện ra trên màn hình sẽ như thế này:
Configured for development.
=> address: localhost
=> port: 8000
=> log: normal
=> workers: 8
=> secret key: generated
=> limits: forms = 32KiB
=> tls: disabled
Mounting '/':
=> GET /
Rocket has launched from http://localhost:8000
Khi truy cập vào địa chỉ http://localhost:8000
bạn sẽ thấy in ra trên màn hình dòng chữ:
Wheresoever you go, go with all your heart.
Vậy là chúng ta đã làm được một trang web đơn giản bằng rocket.rs
rồi. Bây giờ chúng ta tiếp tục build ứng dụng HackerNews thôi.
Build HackerNews Frontend
Để cho đơn giản, thì ứng dụng của chúng ta chỉ cần 1 route duy nhất, đó là /
, và ngay khi truy cập vào trang chủ thì chúng ta sẽ hiển thị danh sách các bản tin luôn.
Đầu tiên, chúng ta thay đổi hàm main
một chút như sau:
main.rs
#![feature(plugin)]
#![plugin(rocket_codegen)]
extern crate rss;
extern crate rocket;
extern crate rocket_contrib;
extern crate serde;
#[macro_use] extern crate serde_derive;
#[macro_use] extern crate serde_json;
mod fetch;
use fetch::*;
use rocket_contrib::Template;
const RSS_URL: &str = "https://news.ycombinator.com/rss";
#[get("/")]
fn index() -> Template {
let news = fetch_from(RSS_URL).ok().expect("Could not read RSS");
Template::render("index", &news)
}
fn main() {
rocket::ignite()
.mount("/", routes![index])
.attach(Template::fairing())
.launch();
}
Trong đoạn code trên, ta khai báo một hằng RSS_URL
để lưu địa chỉ RSS cần parse. Và sử dụng hàm fetch_from
đã tạo ra trong module fetch
để lấy về danh sách các mẫu tin.
Ta cũng sử dụng một cấu trúc dữ liệu mới là rocket_contrib::Template
, khi một handler function trả về kiểu dữ liệu này, thì rocket.rs
sẽ tự động render file template tương ứng ra màn hình.
Tiếp đến ta tạo file template. Ở trong thư mục hackernews-rs
, tạo một thư mục tên templates
$ mkdir templates
$ cd templates
Trong thư mục này, ta tạo một file .html.hbs
, là file template của Handlerbars
:
$ touch index.html.hbs
Nội dung file index.html.hbs
như sau:
index.html.hbs
<html>
<head>
<title>Make HackerNews Great Again!</title>
<style>
</style>
</head>
<body>
<ul>
{{#each this }}
<li class="item">
<div class="title"><a href="{{ link }}">{{ title }}</a></div>
<div class="description">{{{ description }}}</div>
<div class="metadata">{{ pub_date }}</div>
</li>
{{/each}}
</ul>
</body>
</html>
Hàm Template:render
thực hiện việc đọc file template (ở đây là index.html.hbs
) đồng thời truyền vào một biến, gọi là context, từ đó ta có thể truy xuất biến nào thông qua đối tượng this
trong file template.
Ở trong file index.html.hbs
trên, ta có context chính là đối tượng news, là một Vector các bản tin RSSItem
, truy xuất thông qua đối tượng this
. Hàm #each
có tác dụng duyệt qua từng phần tử của vector này và với mỗi phần tử, thì in đoạn nội dung bên trong (thẻ <li>
) ra màn hình.
Đến đay bạn có thể chạy thử để xem kết quả, bằng lệnh:
cargo run
Khi truy cap vào địa chỉ http://localhost:8000
, danh sách các mẫu tin từ HackerNews sẽ hiện ra. Tuy nhiên giao diện lúc này vẫn còn khá xấu. Ta có thể thay đổi file index.html.hbs
để chỉnh sửa giao diện lại một tí:
index.html.hbs
<html>
<head>
<title>Make HackerNews Great Again!</title>
<link href="https://fonts.googleapis.com/css?family=PT+Sans" rel="stylesheet">
<style>
html {
padding: 0; margin: 0;
}
body {
font-family: 'PT Sans', sans-serif;
font-size: 16px;
line-height: 22px;
display: flex;
flex-direction: row;
padding: 0; margin: 0;
}
ul {
margin: 0; padding: 10px;
list-style: none;
counter-reset: news-item-counter;
flex-basis: 400px;
}
ul li {
padding: 10px 10px 10px 30px;
position: relative;
margin: 0;
cursor: pointer;
}
ul li:before {
content: counter(news-item-counter);
counter-increment: news-item-counter;
position: absolute;
top: 10px; left: 0;
font-size: 0.8em;
text-align: center;
line-height: 24px;
width: 24px; height: 24px;
background: #27ae60;
color: #FFF;
font-weight: bold;
border-radius: 3px;
}
ul li a {
text-decoration: none;
font-weight: bold;
color: #27ae60;
}
.metadata {
font-size: 0.8em;
}
#frame {
flex: 1;
border: none;
border-left: 1px solid #27ae60;
}
</style>
</head>
<script>
const loadPage = (url) => {
let frame = document.getElementById("frame");
frame.src = url;
}
</script>
<body>
<ul>
{{#each this }}
<li class="item" onclick="loadPage('{{ link }}')">
<div class="title"><a href="{{ link }}">{{ title }}</a></div>
<div class="metadata">{{ pub_date }} - {{{ description }}}</div>
</li>
{{/each}}
</ul>
<iframe id="frame"></iframe>
</body>
</html>
Ở đoạn HTML trên, chúng ta chia màn hình ra thành 2 phần, một phần bên trái hiển thị danh sách các mẫu tin và một phần bên phải hiển thị nội dung của mẫu tin đó (sử dụng iframe
). Giao diện sẽ như thế này:
Vậy là chúng ta đã hoàn thành ứng dụng đọc tin HackerNews rồi :D
Giải pháp dùng iframe
này chưa thực sự tối ưu, và vẫn còn khá nhiều việc cần phải làm như là loading bar hay handle lỗi khi không thể load được nội dung bài báo vào iframe
.
Tuy nhiên sứ mạng của bài viết này - đó là hướng dẫn làm một ứng dụng web từ A-Z bằng Rust - thì đã kết thúc, cho nên mình sẽ phủi tay và việc fix bug, tối ưu hóa mình sẽ không viết tiếp nữa (dài quá lười thôi, không có gì đâu).