Open13

Rust初心者のWebエンジニアが自作ブラウザを作ってみようとする

梅雨梅雨

とりあえず現在の環境

  • Chip: Apple M2 Max
  • OS: macOS Sequoia 15.1
  • Rust: 1.81.0
梅雨梅雨

まずは手始めにGUIウィンドウを表示するところから始める

Rustプロジェクトを作成

$ cargo init

次にGUIライブラリにどんなものがあるか調べたところ、minifbというものが良いらしい
https://scrapbox.io/appbirdNotebook-public/Rust_で_画面_の_描画_に使えるライブラリとその基本的な使用方法

minifbをインストール

$ cargo add minifb

方針:一次元配列でバッファを用意し、レンダリングする

どうやらRustではletで変数定義し、イミュータブルな変数にはさらにmutを宣言するらしい

https://qiita.com/hiro4669/items/1eea8c6443e7b533ea03

src/main.rs
fn main() {
  let width = 640;
  let height = 480;
  let mut buffer = vec![0; width * height];
}

続いてウィンドウのセットアップ
Rustではuseでモジュールが使えるらしい(JavaScriptでいうところのimportかな)
分割インポートみたいなこともできた

https://zenn.dev/mebiusbox/books/22d4c1ed9b0003/viewer/c12c17

Window::newはResult型を返すため、unwrap_or_elseメソッドでエラーハンドリングを行っている
引数の受け取り方がRubyみたい
エラーハンドリングは正直TypeScriptでもまだ正解が分かっていない()ので、一旦流す

https://qiita.com/komasayuki/items/8a70058bb13e394d834d

src/main.rs
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にできた

https://qiita.com/k-yaina60/items/b522c57c518fbf69a38d#iter_mut

src/main.rs
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

梅雨梅雨

流石にバッファだけで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に基づいた構成である

https://guide.elm-lang.org/architecture/

Elmは以下の3つの要素をコアとする

  • Model: アプリケーションの状態
  • View: 各状態に対応する見た目
  • Update: メッセージに基づいて状態を更新するロジック

Vue.jsやReactに採用されているリアクティブシステムにかなり似ている印象を受ける

とりあえず公式のFirst Stepsを参考に簡単なカウントアプリを作ってみる

https://book.iced.rs/first-steps.html

梅雨梅雨

icedをインストール
バージョンは0.13.1が入った

$ cargo add iced

まずはステート(状態)を用意する。今回必要なステートはvalueで、型はi32とした
Rustには数値の型がたくさんあり、符号の有無やビット数を細かく指定できるらしい

https://isub.co.jp/rust/rust-whole-number-floating-point/

構造体はごく一般的な書き方で書ける

src/main.rs
struct Counter {
  value: i32,
}

続けてメッセージを用意する。列挙型もごくごく普通の記法

src/main.rs
enum Message {
  Increment,
  Decrement,
}

順番に0から数値が振られるが、自前で数値をあてることも可能

src/main.rs
enum Message {
  Increment = 1,
  Decrement = 2,
}

次に、Counterのvalueを更新するロジックを記述する
第一引数にCounter型オブジェクトへの参照、第二引数にメッセージを受ける関数とした
これはどうやらicedのupdate関数として決まっているらしい(あとでiced::runの引数に渡すため)

Rustにおける参照渡しは基本的にC++と同じように使えそう

https://zenn.dev/toga/books/rust-atcoder-old/viewer/26-call-by-reference

src/main.rs
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>の意味など、多少気になる部分はあったがとりあえず今回はスキップで

src/main.rs
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)
  ]
}

以上までのコードをまとめると以下のようになる

src/main.rs
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();
}

このままだとコンパイラに怒られるので、お叱り通りステートとメッセージの前にアノテーションを記述して終了

src/main.rs
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の基礎が学べたので、次はユーザの入力を受け付けてみる

https://docs.iced.rs/iced/widget/text_input/index.html

結構簡単に実装できた

src/main.rs
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_inputbuttonrowで横並びにし、URL Searchができるようにする
まずはbuttonが押されたら入力されているURLを出力するところまで

src/main
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にパッケージを記述

https://hyper.rs/guides/1/client/basic/

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関数全体の処理をブロックし非同期関数を同期的に扱うことができるようになる

src/search.rs
#[tokio::main]
pub async fn search(url_str: &str) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
  return Ok("".to_string());
}

Rustは関数内の最後の式の評価結果が返り値となるため以下のような書き方もできる
(Rubyを書いていた時もそうだったけど、個人的にはあまり気に入らない)

src/search.rs
#[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の静的解析がうまくいかず型エラーになるが次のステップで治るので無視

src/search.rs
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リクエストを送信
これでレスポンスが取得できた

src/search.rs
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)でボディを返却して終了

src/search.rs
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のコード

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文字列を保持できるようにする

src/main.rs
#[derive(Default)]
struct State {
  url_str: String,
  html_str: String,
}

続いてメッセージを変更
Searchは検索ボタンが押された時に、SearchCompleted(String)は検索結果が返却された時にそれぞれ送られる

src/main.rs
#[derive(Debug, Clone)]
enum Message {
  ContentChanged(String),
  Search,
  SearchCompleted(String),
}

アップデート関数は以下のように実装した
Task::performは関数内で非同期処理を実行することができるicedのcrateであり、第一引数の返り値が第二引数に渡って実行される(0.13.0より前はCommand::perform

src/main.rs
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には文字列の参照を渡さないとエラーが起きた

src/main.rs
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();
}

最終的なコードは以下のようになった

src/main.rs
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();
}

梅雨梅雨

若干見た目を修正

src/main.rs
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://docs.rs/html5ever/latest/html5ever/

パーサの実装には以下のサイトが非常に役に立った

https://browserbook.shift-js.info/

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と呼ぶこととし、次のような構造体で表す

src/parser.rs
use std::collections::HashMap;

struct Element {
  tag_name: String,
  attributes: HashMap<String, String>,
}

Example Domain More information...のような文字列要素をTextと呼ぶこととする
Textは純粋なStringであるが、便宜上構造体でラップしておく

src/parser.rs
struct Text {
  value: String,
}

各行はElementまたはTextで表現できるため、この2つの構造体をNodeTypeとして1つの列挙型にまとめる

src/parser.rs
enum NodeType {
  Text(Text),
  Element(Element),
}

各行をNodeと呼ぶこととし、木構造を表現するために自身の型の配列をchildrenで持てるようにした

src/parser.rs
struct Node {
  node_type: NodeType,
  children: Vec<Node>,
}

ここまでのコード

src/parser.rs
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関数を定義する

src/parser.rs
fn tokenizer(html_str: &str) -> Vec<&str> {
  let mut token = String::new();
  let mut tokens = Vec::new();

  return tokens;
}

以下のテストがpassすることを目標にする

src/parser.rs
#[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に追加し、tokentokensに追加したあと空にする
  • それ以外は対象の文字をtokenに追加
src/parser.rs
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

これで文字列のトークン分割が(とりあえず)できた