Rust初心者のWebエンジニアが自作ブラウザを作ってみようとする
とりあえず現在の環境
- Chip: Apple M2 Max
- OS: macOS Sequoia 15.1
- Rust: 1.81.0
まずは手始めにGUIウィンドウを表示するところから始める
Rustプロジェクトを作成
$ cargo init
次にGUIライブラリにどんなものがあるか調べたところ、minifbというものが良いらしい
minifbをインストール
$ cargo add minifb
方針:一次元配列でバッファを用意し、レンダリングする
どうやらRustではlet
で変数定義し、イミュータブルな変数にはさらにmut
を宣言するらしい
fn main() {
let width = 640;
let height = 480;
let mut buffer = vec![0; width * height];
}
続いてウィンドウのセットアップ
Rustではuse
でモジュールが使えるらしい(JavaScriptでいうところのimport
かな)
分割インポートみたいなこともできた
Window::newはResult
型を返すため、unwrap_or_else
メソッドでエラーハンドリングを行っている
引数の受け取り方がRubyみたい
エラーハンドリングは正直TypeScriptでもまだ正解が分かっていない()ので、一旦流す
use minifb::{Window, WindowOptions};
fn main() {
let width = 640;
let height = 480;
let mut buffer = vec![0; width * height];
let mut window = Window::new("Rust Window", width, height, WindowOptions::default())
.unwrap_or_else(|e| {
panic!("{}", e);
});
}
ここまできたらあとはバッファに適当な値を書き込んでループ処理
バッファのイテレータで各要素の可変参照をするにはiter_mut
メソッドを使えば良いそう
ポインタ変数のアクセスはC言語と似たような感じ
整数リテラルの前に0xを置くだけでHexにできた
use minifb::{Window, WindowOptions};
fn main() {
let width = 640;
let height = 480;
let mut buffer = vec![0; width * height];
let mut window = Window::new("Rust Window", width, height, WindowOptions::default())
.unwrap_or_else(|e| {
panic!("{}", e);
});
while window.is_open() && !window.is_key_down(minifb::Key::Escape) {
for i in buffer.iter_mut() {
*i = 0x000000;
}
window.update_with_buffer(&buffer, width, height).unwrap();
}
}
これでとりあえずGUIウィンドウの表示ができた
道のりは長そう
$ cargo run
Rustではcrate(クレート)
という概念でソースコードを扱うらしい
正直パッケージやライブラリとの違いがまだよくわかってない
流石にバッファだけでGUIを描画するわけにもいかないので、もう少し高度なUIライブラリを探す
- Tauri
- Iced
- egui
これらがRustのGUIフレームワーク三大巨頭らしい
TauriはフロントエンドをReactなどのモダンなライブラリで記述でき、あのElectronよりも勢いのあるイケてるフレームワーク
icedは「保持モード」で動作するGUIライブラリで、調べてみた感じReactのようにステート管理が必要
eguiは「即時モード」で動作するGUIライブラリで、icedとは反対にフレーム処理を行うらしい
Tauriはプロダクト開発なら採用だったけど、今回はRustで低レイヤーな開発を行うことが目的なのでで却下
少し悩んだが、フレーム処理はUnityでお腹いっぱいなので今回はWebエンジニアらしくicedを採用してみる
まずはicedをインストールする
バージョンは0.13.1が入った
$ cargo add iced
icedはElmというGUI開発向けプログラミング言語にインスピレーションを受けており、The Elm Architecture
に基づいた構成である
Elmは以下の3つの要素をコアとする
- Model: アプリケーションの状態
- View: 各状態に対応する見た目
- Update: メッセージに基づいて状態を更新するロジック
Vue.jsやReactに採用されているリアクティブシステムにかなり似ている印象を受ける
とりあえず公式のFirst Stepsを参考に簡単なカウントアプリを作ってみる
icedをインストール
バージョンは0.13.1が入った
$ cargo add iced
まずはステート(状態)を用意する。今回必要なステートはvalue
で、型はi32
とした
Rustには数値の型がたくさんあり、符号の有無やビット数を細かく指定できるらしい
構造体はごく一般的な書き方で書ける
struct Counter {
value: i32,
}
続けてメッセージを用意する。列挙型もごくごく普通の記法
enum Message {
Increment,
Decrement,
}
順番に0から数値が振られるが、自前で数値をあてることも可能
enum Message {
Increment = 1,
Decrement = 2,
}
次に、Counterのvalue
を更新するロジックを記述する
第一引数にCounter型オブジェクトへの参照、第二引数にメッセージを受ける関数とした
これはどうやらicedのupdate関数として決まっているらしい(あとでiced::run
の引数に渡すため)
Rustにおける参照渡しは基本的にC++と同じように使えそう
fn update(counter: &mut Counter, message: Message) {
match message {
Message::Increment => {
counter.value += 1;
}
Message::Decrement => {
counter.value -= 1;
}
}
}
case文のような条件分岐はmatch
節によって実現でき、条件にマッチした段階で=>の右辺のブロック式を実行する
最後に、状態に対応する見た目を記述する
Reactにおける関数コンポーネントのように記述できた
謎のcolumn!
の!
や、Column<Message>
の意味など、多少気になる部分はあったがとりあえず今回はスキップで
use iced::widget::{button, column, text, Column};
fn view(counter: &Counter) -> Column<Message> {
column![
button("+").on_press(Message::Increment),
text(counter.value),
button("-").on_press(Message::Decrement)
]
}
以上までのコードをまとめると以下のようになる
use iced::widget::{button, column, text, Column};
struct Counter {
value: i32,
}
enum Message {
Increment,
Decrement,
}
fn update(counter: &mut Counter, message: Message) {
match message {
Message::Increment => {
counter.value += 1;
}
Message::Decrement => {
counter.value -= 1;
}
}
}
fn view(counter: &Counter) -> Column<Message> {
column![
button("+").on_press(Message::Increment),
text(counter.value),
button("-").on_press(Message::Decrement)
]
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
このままだとコンパイラに怒られるので、お叱り通りステートとメッセージの前にアノテーションを記述して終了
use iced::widget::{button, column, text, Column};
#[derive(Default)]
struct Counter {
value: i32,
}
#[derive(Debug, Clone)]
enum Message {
Increment,
Decrement,
}
fn update(counter: &mut Counter, message: Message) {
match message {
Message::Increment => {
counter.value += 1;
}
Message::Decrement => {
counter.value -= 1;
}
}
}
fn view(counter: &Counter) -> Column<Message> {
column![
button("+").on_press(Message::Increment),
text(counter.value),
button("-").on_press(Message::Decrement)
]
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
実行確認
無事カウントアプリが作れた
$ cargo run
GUIの基礎が学べたので、次はユーザの入力を受け付けてみる
結構簡単に実装できた
use iced::{widget::text_input, Element};
#[derive(Default)]
struct State {
content: String,
}
#[derive(Debug, Clone)]
enum Message {
ContentChanged(String),
}
fn update(state: &mut State, message: Message) {
match message {
Message::ContentChanged(content) => {
state.content = content;
}
}
}
fn view(state: &State) -> Element<Message> {
text_input("Enter URL", &state.content)
.on_input(Message::ContentChanged)
.into()
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
text_input
とbutton
をrow
で横並びにし、URL Searchができるようにする
まずはbutton
が押されたら入力されているURLを出力するところまで
use iced::{
widget::{button, row, text_input},
Element,
};
#[derive(Default, Debug, Clone)]
struct State {
content: String,
}
#[derive(Debug, Clone)]
enum Message {
ContentChanged(String),
Search,
}
fn update(state: &mut State, message: Message) {
match message {
Message::ContentChanged(content) => {
state.content = content;
}
Message::Search => {
println!("URL: {}", state.content);
}
}
}
fn view(state: &State) -> Element<Message> {
let text_input = text_input("Enter URL", &state.content).on_input(Message::ContentChanged);
let search_button = button("search").on_press(Message::Search);
return row![text_input, search_button].into();
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
Rustのhttp clientを探してみたところ、
- hyper
- reqwest
のようなものが見つかった
hyperの方が拡張性が高そうなので、今回は試しにhyperを採用してみる
ドキュメントを参考にCargo.toml
にパッケージを記述
[dependencies]
iced = "0.13.1"
hyper = { version = "1", features = ["full"] }
tokio = { version = "1", features = ["full"] }
http-body-util = "0.1"
hyper-util = { version = "0.1", features = ["full"] }
パッケージをインストール
$ cargo build
方針:受け取ったURLに対してGETリクエストを送り、返却されたレスポンスからボディを取り出す
ドキュメントに従ってコードを組み立てていく
#[tokio::main]
はマクロであり、tokioの非同期ランタイムを起動することでmain関数全体の処理をブロックし非同期関数を同期的に扱うことができるようになる
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
return Ok("".to_string());
}
Rustは関数内の最後の式の評価結果が返り値となるため以下のような書き方もできる
(Rubyを書いていた時もそうだったけど、個人的にはあまり気に入らない)
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
Ok("".to_string())
}
次にTCPコネクションを開始
この際、hyper::client::conn::http1::handshake
の静的解析がうまくいかず型エラーになるが次のステップで治るので無視
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = url_str.parse::<hyper::Uri>()?;
let host = url.host().expect("uri has no host");
let port = url.port_u16().unwrap_or(80);
let address = format!("{}:{}", host, port);
let stream = TcpStream::connect(address).await?;
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::task::spawn(async move {
if let Err(err) = conn.await {
println!("Connection failed: {:?}", err);
}
});
return Ok("".to_string());
}
続けてGETリクエストを送信
これでレスポンスが取得できた
use http_body_util::Empty;
use hyper::{body::Bytes, Request};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = url_str.parse::<hyper::Uri>()?;
let host = url.host().expect("uri has no host");
let port = url.port_u16().unwrap_or(80);
let address = format!("{}:{}", host, port);
let stream = TcpStream::connect(address).await?;
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::task::spawn(async move {
if let Err(err) = conn.await {
println!("Connection failed: {:?}", err);
}
});
let authority = url.authority().unwrap().clone();
let req = Request::builder()
.uri(url)
.header(hyper::header::HOST, authority.as_str())
.body(Empty::<Bytes>::new())?;
let mut res = sender.send_request(req).await?;
println!("Response status: {}", res.status());
return Ok("".to_string());
}
最後にレスポンスボディを1フレームずつ取り出して文字列として連結していく
Ok(body)
でボディを返却して終了
use http_body_util::BodyExt;
use http_body_util::Empty;
use hyper::{body::Bytes, Request};
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
let url = url_str.parse::<hyper::Uri>()?;
let host = url.host().expect("uri has no host");
let port = url.port_u16().unwrap_or(80);
let address = format!("{}:{}", host, port);
let stream = TcpStream::connect(address).await?;
let io = TokioIo::new(stream);
let (mut sender, conn) = hyper::client::conn::http1::handshake(io).await?;
tokio::task::spawn(async move {
if let Err(err) = conn.await {
println!("Connection failed: {:?}", err);
}
});
let authority = url.authority().unwrap().clone();
let req = Request::builder()
.uri(url)
.header(hyper::header::HOST, authority.as_str())
.body(Empty::<Bytes>::new())?;
let mut res = sender.send_request(req).await?;
println!("Response status: {}", res.status());
let mut body = String::new();
while let Some(next) = res.frame().await {
let frame = next?;
if let Some(chunk) = frame.data_ref() {
body.push_str(&String::from_utf8_lossy(chunk));
}
}
return Ok(body);
}
現状のsrc/main.rs
のコード
use iced::{
widget::{button, row, text_input},
Element,
};
#[derive(Default, Debug, Clone)]
struct State {
content: String,
}
#[derive(Debug, Clone)]
enum Message {
ContentChanged(String),
Search,
}
fn update(state: &mut State, message: Message) {
match message {
Message::ContentChanged(content) => {
state.content = content;
}
Message::Search => {
println!("URL: {}", state.content);
}
}
}
fn view(state: &State) -> Element<Message> {
let text_input = text_input("Enter URL", &state.content).on_input(Message::ContentChanged);
let search_button = button("search").on_press(Message::Search);
return row![text_input, search_button].into();
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
方針:URLを入力してSearchボタンを押したら下にHTMLの文字列が表示されるようにする
まずはステートを変更
URL文字列と、取得したHTML文字列を保持できるようにする
#[derive(Default)]
struct State {
url_str: String,
html_str: String,
}
続いてメッセージを変更
Search
は検索ボタンが押された時に、SearchCompleted(String)
は検索結果が返却された時にそれぞれ送られる
#[derive(Debug, Clone)]
enum Message {
ContentChanged(String),
Search,
SearchCompleted(String),
}
アップデート関数は以下のように実装した
Task::perform
は関数内で非同期処理を実行することができるicedのcrateであり、第一引数の返り値が第二引数に渡って実行される(0.13.0より前はCommand::perform
)
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::ContentChanged(url_str) => {
state.url_str = url_str;
Task::none()
}
Message::Search => {
let url_str = state.url_str.clone();
Task::perform(
async move { search::search(&url_str).unwrap() },
Message::SearchCompleted,
)
}
Message::SearchCompleted(html_str) => {
state.html_str = html_str;
Task::none()
}
}
}
ビューは以下のように横並びのsearch_bar
を作り、その下にhtml_str
を配置
text
には文字列の参照を渡さないとエラーが起きた
use iced::{
widget::{button, column, row, text, text_input},
Element, Task,
};
fn view(state: &State) -> Element<Message> {
let text_input = text_input("Enter URL", &state.url_str).on_input(Message::ContentChanged);
let search_button = button("search").on_press(Message::Search);
let search_bar = row![text_input, search_button];
return column![search_bar, text(&state.html_str)].into();
}
最終的なコードは以下のようになった
use iced::{
widget::{button, column, row, text, text_input},
Element, Task,
};
mod search;
#[derive(Default)]
struct State {
url_str: String,
html_str: String,
}
#[derive(Debug, Clone)]
enum Message {
ContentChanged(String),
Search,
SearchCompleted(String),
}
fn update(state: &mut State, message: Message) -> Task<Message> {
match message {
Message::ContentChanged(url_str) => {
state.url_str = url_str;
Task::none()
}
Message::Search => {
let url_str = state.url_str.clone();
Task::perform(
async move { search::search(&url_str).unwrap() },
Message::SearchCompleted,
)
}
Message::SearchCompleted(html_str) => {
state.html_str = html_str;
Task::none()
}
}
}
fn view(state: &State) -> Element<Message> {
let text_input = text_input("Enter URL", &state.url_str).on_input(Message::ContentChanged);
let search_button = button("search").on_press(Message::Search);
let search_bar = row![text_input, search_button];
return column![search_bar, text(&state.html_str)].into();
}
fn main() {
iced::run("Sample App", update, view).unwrap();
}
若干見た目を修正
use iced::{
widget::{button, column, row, text, text_input},
window::settings::{PlatformSpecific, Settings},
Element, Size, Task,
};
fn main() -> iced::Result {
return iced::application("Sample App", update, view)
.window(Settings {
size: Size {
width: 640.0,
height: 480.0,
},
platform_specific: PlatformSpecific {
titlebar_transparent: true,
title_hidden: true,
fullsize_content_view: true,
},
..Default::default()
})
.run();
}
今度はHTML文字列をパースする処理を書いていく
Mozillaによって開発されているServoのHTMLパーサであるhtml5ever
を使いたい気持ちは山々だが、一旦トークナイズとDOMツリーの構築を自前で実装してみる
パーサの実装には以下のサイトが非常に役に立った
https://example.com
から返却されたHTMLのうち、以下のbodyのみに注目してパースしていく
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
まずは、このような断片を考える
<a href="https://www.iana.org/domains/example">More information...</a>
この断片を仮に以下のような表記で書くこととする
<a> { href: "https://www.iana.org/domains/example" }
└─ More information...
これをbody以下のHTMLに適用すると、次のようになる
<body>
├─ <h1>
│ └─ Example Domain
├─ <p>
│ └─ This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.
└─ <p>
└─ <a> { href: "https://www.iana.org/domains/example" }
└─ More information...
次に、この表記において各行をオブジェクト化していくことを考える
<h1>
や<a> { href: "https://www.iana.org/domains/example" }
のようなHTML要素をElement
と呼ぶこととし、次のような構造体で表す
use std::collections::HashMap;
struct Element {
tag_name: String,
attributes: HashMap<String, String>,
}
Example Domain
や More information...
のような文字列要素をText
と呼ぶこととする
Text
は純粋なStringであるが、便宜上構造体でラップしておく
struct Text {
value: String,
}
各行はElement
またはText
で表現できるため、この2つの構造体をNodeType
として1つの列挙型にまとめる
enum NodeType {
Text(Text),
Element(Element),
}
各行をNode
と呼ぶこととし、木構造を表現するために自身の型の配列をchildren
で持てるようにした
struct Node {
node_type: NodeType,
children: Vec<Node>,
}
ここまでのコード
use std::collections::HashMap;
struct Node {
node_type: NodeType,
children: Vec<Node>,
}
enum NodeType {
Text(Text),
Element(Element),
}
struct Text {
value: String,
}
struct Element {
tag_name: String,
attributes: HashMap<String, String>,
}
次のようなHTML文字列が入力されたとき、トークンごとに分割していく必要がある
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
このステップでは木構造のことは一旦おいておき、純粋にトークンとしてバラしていく
[
"<body>",
"<div>",
"<h1>",
"Example Domain",
"</h1>",
"<p>",
"<a href=\"https://www.iana.org/domains/example\">",
"More information...",
"</a>",
"</p>",
"</div>",
"</body>"
]
以下のようにtokenizer
関数を定義する
fn tokenizer(html_str: &str) -> Vec<&str> {
let mut token = String::new();
let mut tokens = Vec::new();
return tokens;
}
以下のテストがpassすることを目標にする
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tokenizer() {
let html_str = "<h1>Example Domain</h1>";
let tokens = tokenizer(html_str);
assert_eq!(tokens, vec!["<h1>", "Example Domain", "</h1>"]);
let html_str = "<a href=\"https://www.iana.org/domains/example\">More information...</a>";
let tokens = tokenizer(html_str);
assert_eq!(
tokens,
vec![
"<a href=\"https://www.iana.org/domains/example\">",
"More information...",
"</a>",
]
);
let html_str =
"<p><a href=\"https://www.iana.org/domains/example\">More information...</a></p>";
let tokens = tokenizer(html_str);
assert_eq!(
tokens,
vec![
"<p>",
"<a href=\"https://www.iana.org/domains/example\">",
"More information...",
"</a>",
"</p>",
]
);
}
}
HTML文字列を前から順番に1文字ずつ読んでいき、
-
<
のときはtoken
が空でなければtokens
に追加し、token
を空にしたあと対象の文字をtoken
に追加 -
>
のときは対象の文字をtoken
に追加し、token
をtokens
に追加したあと空にする - それ以外は対象の文字を
token
に追加
fn tokenizer(html_str: &str) -> Vec<String> {
let mut token = String::new();
let mut tokens = Vec::new();
for c in html_str.chars() {
match c {
'<' => {
if !token.trim().is_empty() {
tokens.push(token.trim().to_string());
}
token.clear();
token.push(c);
}
'>' => {
token.push(c);
tokens.push(token.clone());
token.clear();
}
_ => {
token.push(c);
}
}
}
return tokens;
}
$ cargo test
running 1 test
test parser::tests::test_tokenizer ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
これで文字列のトークン分割が(とりあえず)できた